fix(decode): stop the PD family losing every saturated colour - #64
fix(decode): stop the PD family losing every saturated colour#64bucknova wants to merge 2 commits into
Conversation
The batch decoder's chroma sampler clamped any chroma reading under 15% of the signalling band (byte 38) to neutral 128. Chroma is coded 0-255 *around* a neutral 128, so a low value is a fully saturated pixel, not a nearly-grey one — the clamp erased exactly the most colourful part of every frame. Saturated yellows, cyans and greens decoded pale and washed out. On the round-trip audit PD's mean pixel error was 9.2 against under 1.0 for every RGB-family mode; it is 2.7 now. The clamp was added to suppress a Robot 36 right-edge artifact, but Robot 36 moved to its own slowrx-derived sampler long ago, so the only caller it ever reached was PD. The incremental decoder already had this right — it was fixed there and documented as a divergence, and the fix never came back to the batch copy. Both now take the reject threshold from core.demod so they cannot drift apart again, and a test sweeps the chroma range through both and requires byte-for-byte agreement. Second, narrower bug found by the new saturated-yellow round-trip test: a byte-0 chroma scan transmits at exactly 1500 Hz, the bottom of the band, so demodulator jitter puts about half the readings a fraction of a hertz below it. Both decoders substituted neutral 128 for those, salting large saturated areas with grey at roughly 50%. Sub-black chroma now clamps to byte 0; real out-of-band leakage is still rejected by frequency, above the sync band. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Correction to the PR body above, and wider verificationKevin asked whether I had actually verified every mode. I had not, and checking properly turned up two things — one of them a defect in this PR's own reporting. 1. Three "before" numbers in the table above were not measurementsI measured baselines for 2. The audit script covered 17 of the 22 modes in
|
| mode | before | after | delta | pixels identical |
|---|---|---|---|---|
| pd_50 | 9.63 | 3.03 | −6.60 | no — changed |
| pd_90 | 9.43 | 2.83 | −6.60 | no — changed |
| pd_120 | 9.28 | 2.81 | −6.47 | no — changed |
| pd_160 | 9.23 | 2.66 | −6.57 | no — changed |
| pd_180 | 9.21 | 2.72 | −6.49 | no — changed |
| pd_240 | 9.18 | 2.68 | −6.50 | no — changed |
| pd_290 | 9.15 | 2.67 | −6.48 | no — changed |
| robot_36 | 3.15 | 3.15 | 0.00 | yes |
| martin_m1 | 0.44 | 0.44 | 0.00 | yes |
| martin_m2 | 0.78 | 0.78 | 0.00 | yes |
| martin_m3 | 0.46 | 0.46 | 0.00 | yes |
| martin_m4 | 0.90 | 0.90 | 0.00 | yes |
| scottie_s1 | 0.47 | 0.47 | 0.00 | yes |
| scottie_s2 | 0.57 | 0.57 | 0.00 | yes |
| scottie_s3 | 0.48 | 0.48 | 0.00 | yes |
| scottie_s4 | 0.66 | 0.66 | 0.00 | yes |
| scottie_dx | 0.05 | 0.05 | 0.00 | yes |
| wraase_sc2_120 | 1.00 | 1.00 | 0.00 | yes |
| wraase_sc2_180 | 0.12 | 0.12 | 0.00 | yes |
| pasokon_p3 | 0.51 | 0.51 | 0.00 | yes |
| pasokon_p5 | 0.33 | 0.33 | 0.00 | yes |
| pasokon_p7 | 0.23 | 0.23 | 0.00 | yes |
Exactly the seven PD modes change. The other fifteen decode bit-for-bit identically, which is what you would expect if chroma=True has only one caller — and is now measured rather than asserted.
What this still does not cover
This is the batch decode path (decode_wav). The incremental decoder's sampler is changed by this PR too, and its unit tests pass, but there is no per-mode round-trip harness for the streaming path — it has its own sync tracking and windowing. That gap predates this PR and I am not closing it here; worth a separate issue.
Encode is verified only as far as "decodes back correctly." test_line_time_matches_pysstv independently checks every mode's line_time_ms against PySSTV, which is the property a wrong encoder would break, but nothing in this PR touches the encoder.
73, Kevin/W0AEZ and Claude
The audit's mode list was hand-written and had fallen five modes behind the table: martin_m3, martin_m4, scottie_s3, scottie_s4 and pd_50. It is now derived from MODE_TABLE, so a new mode is audited as soon as it is added. pd_50 is why this matters. It was never audited, and it carried the worst instance of the chroma clamp fixed in the previous commit — MAE 9.63, the highest of the seven PD modes. The audit that found the PD problem could not see the mode that had it worst. Also corrects the CHANGELOG: all seven PD modes are affected, not six, and the per-mode figures are now measured for every one of them rather than quoted for a sample. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What was wrong
The revived
scripts/roundtrip_all_modes.pyaudit showed the whole PD family sitting at a mean absolute pixel error of ~9.2, while every RGB-family mode came in under 1.0. That gap turned out to be two separate chroma bugs, neither of them anything to do with PD's timing or line layout.1. The 15% chroma floor (the big one)
decoder._sample_pixelsreplaced any chroma reading below 15% of the 1500–2300 Hz signalling band — byte 38 — with neutral 128. The comment defending it read:That is exactly backwards. Chroma is coded 0–255 around a neutral 128, so byte 38 is a strongly saturated pixel and byte 0 is fully saturated: Cb≈0 is pure yellow, Cr≈0 pure cyan. The floor replaced the most colourful pixels in the frame with grey.
Measured on the audit image, decoded chroma was literally 128 wherever the true value was below ~38:
The floor was written to suppress a Robot 36 right-edge artifact. Robot 36 moved to its own slowrx-derived sampler long ago, so
chroma=Truehas only one caller left — PD. The guard has been damaging PD and nothing else.Worse: this was already known and already fixed — in the other copy.
incremental_decoder._sample_pixels_inccarries a docstring that describes the bug correctly ("corrupts every saturated yellow / green / cyan pixel"), replaces the floor with a narrow sync-band reject, and has two regression tests. That fix landed in v0.1.13 and never came back to the batch decoder. So for eight minor versions a PD frame decoded live off the air looked right, and the same frame decoded from a WAV import came back washed out.2. The sub-black clamp (found by the new test)
Once the floor was gone, a flat saturated-yellow PD-90 round-trip still came back speckled. A byte-0 chroma scan transmits at exactly 1500 Hz — the bottom of the band — so demodulator jitter puts roughly half the readings a fraction of a hertz below it. Both decoders treated any sub-1500 Hz chroma reading as unusable and substituted neutral 128:
50% grey salt through every fully saturated region — live and from file. Sub-black chroma now clamps to byte 0; genuine out-of-band leakage is still rejected, by frequency, above the sync band.
Result
scripts/roundtrip_all_modes.py, before → after:Non-PD modes are byte-identical — Robot 36 stays at 3.15, Martin M1 at 0.44 — confirming nothing else was on this path.
The residual ~2.7 is expected and not a bug: PD subsamples chroma vertically (one chroma pair per two image rows), and the ~4-pixel right-edge chroma guard is deliberate. Dropping the edge guard as an experiment takes PD to 1.87, at the cost of a green fringe on the last column, which is not a trade worth making.
Preventing the re-drift
The root cause of the eight-version gap is that these two samplers are separate copies. They now share
SSTV_SYNC_REJECT_HZfromcore.demod, andtest_batch_and_incremental_chroma_samplers_agreesweeps the sync band plus the full chroma range through both and requires byte-for-byte equality. If they diverge again, CI says so.Tests
Four new, in
tests/core/test_decoder.py— the first two mirroring the guards that already existed on the incremental side:test_batch_sample_pixels_chroma_does_not_clamp_low_values— byte 10 decodes as ~10, not 128test_batch_sample_pixels_chroma_rejects_sync_band_leakage— sync-band energy still clamps to neutraltest_batch_and_incremental_chroma_samplers_agree— the anti-drift guardtest_pd_roundtrip_preserves_saturated_chroma— full PD-90 encode→decode of a saturated-yellow frame; this is the one that caught bug 2Each was confirmed to fail without its fix. Full suite: 368 core tests pass, 1464 pass overall. The one local failure (
test_qr_rendered_when_enabled_with_token) is the pre-existing missingsegnoin the dev venv and fails identically onmain.Note on scope
This is a decode-quality fix with no UI, config, or wire-format change. Worth calling out that our five-agent adversarial review did not find either bug — both were found by running the codec and measuring the output, same as the Data/Pkt bug in v0.6.10 was found by someone actually using the feature.
73, Kevin/W0AEZ and Claude