diff --git a/tests/test_sstv_decoder.py b/tests/test_sstv_decoder.py index 0c1dcd2..6bcae4d 100644 --- a/tests/test_sstv_decoder.py +++ b/tests/test_sstv_decoder.py @@ -685,6 +685,40 @@ class TestImageDecoder: assert img is not None assert img.size == (320, 240) + def test_slant_correction_wraps_rows_without_blank_wedge(self): + """Slant correction should rotate rows, not introduce black fill.""" + PIL = pytest.importorskip('PIL') + from utils.sstv.image_decoder import SSTVImageDecoder + + decoder = SSTVImageDecoder(SCOTTIE_1) + decoder._sync_deviations = [float(i * 4) for i in range(SCOTTIE_1.height)] + + source = np.full((SCOTTIE_1.height, SCOTTIE_1.width, 3), 128, dtype=np.uint8) + img = PIL.Image.fromarray(source, 'RGB') + + corrected = decoder._apply_slant_correction(img) + corrected_arr = np.array(corrected) + + # If correction clips/fills, zeros appear. Circular shift should preserve all values. + assert corrected_arr.min() == 128 + assert corrected_arr.max() == 128 + + def test_slant_correction_skips_implausible_drift(self): + """Very large estimated drift should be treated as a bad fit and ignored.""" + PIL = pytest.importorskip('PIL') + from utils.sstv.image_decoder import SSTVImageDecoder + + decoder = SSTVImageDecoder(SCOTTIE_1) + decoder._sync_deviations = [float(i * 40) for i in range(SCOTTIE_1.height)] + + source = np.full((SCOTTIE_1.height, SCOTTIE_1.width, 3), 177, dtype=np.uint8) + img = PIL.Image.fromarray(source, 'RGB') + + corrected = decoder._apply_slant_correction(img) + + # Implausible slope should return original image unchanged. + assert np.array_equal(np.array(corrected), source) + # --------------------------------------------------------------------------- # SSTVDecoder orchestrator tests diff --git a/utils/sstv/image_decoder.py b/utils/sstv/image_decoder.py index d60c8cc..c90a6be 100644 --- a/utils/sstv/image_decoder.py +++ b/utils/sstv/image_decoder.py @@ -397,9 +397,9 @@ class SSTVImageDecoder: Uses the sync deviation measurements collected during decoding to estimate the per-line SDR clock drift rate via linear regression, - then shears the image to compensate. Noisy individual measurements - are averaged out; if fewer than 10 valid measurements exist the - image is returned unchanged. + then circularly shifts each row to compensate. Noisy individual + measurements are averaged out; if fewer than 10 valid measurements + exist the image is returned unchanged. """ valid = [(i, d) for i, d in enumerate(self._sync_deviations) if d is not None] @@ -423,16 +423,19 @@ class SSTVImageDecoder: arr = np.array(img) height, width = arr.shape[:2] - corrected = np.zeros_like(arr) + + # Reject clearly implausible estimates. Even with cheap SDR clocks, + # real SSTV slant is typically modest; extreme values are usually + # bad sync picks that would over-correct the image. + total_shift = abs((height - 1) * pixels_per_line) + if total_shift > width * 0.25: + return img + + corrected = np.empty_like(arr) for row in range(height): shift = -int(round(row * pixels_per_line)) - if shift == 0: - corrected[row] = arr[row] - elif shift > 0: - corrected[row, shift:] = arr[row, :width - shift] - else: - corrected[row, :width + shift] = arr[row, -shift:] + corrected[row] = np.roll(arr[row], shift=shift, axis=0) return Image.fromarray(corrected, 'RGB')