From 83a54ccb20d8d4921ca7f7841c8c98875d594b88 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 19 Feb 2026 10:29:19 +0000 Subject: [PATCH] fix: Replace coarse Scottie sync search with vectorised fine scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The step-49 coarse scan introduced up to ±24 sample uncertainty in R channel placement. When accumulated SDR clock drift pushed the actual sync 35+ samples early in the search region, the step-49 windows could land on the B-pixel tail and return position 0, misplacing R by ~50 samples (~16 pixel colour shift) — worse than no correction at all. Replace with a vectorised goertzel_batch sliding-window scan at step=1 over a short window (sync_duration / 3 ≈ 3 ms), giving single-sample accuracy. Use consumed=pos (instead of max(pos,line_samples)) when the sync is found, so the next line starts at its correct separator and per-line timing errors stop accumulating entirely. Falls back to the fixed-offset path whenever the sync is not found (e.g. noisy signal), preserving the pre-change baseline quality. Co-Authored-By: Claude Sonnet 4.6 --- utils/sstv/image_decoder.py | 66 ++++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 19 deletions(-) diff --git a/utils/sstv/image_decoder.py b/utils/sstv/image_decoder.py index 5d31205..e16985f 100644 --- a/utils/sstv/image_decoder.py +++ b/utils/sstv/image_decoder.py @@ -20,6 +20,7 @@ from .constants import ( ) from .dsp import ( goertzel, + goertzel_batch, samples_for_duration, ) from .modes import ( @@ -193,6 +194,9 @@ class SSTVImageDecoder: search_margin = max(100, self._line_samples // 10) line_start = 0 + # Set True when the Scottie mid-line sync is found precisely so + # that we can use consumed=pos to stop timing errors accumulating. + _sync_corrected = False if self._mode.sync_position in (SyncPosition.FRONT, SyncPosition.FRONT_PD): # Sync is at the beginning of each line @@ -235,16 +239,16 @@ class SSTVImageDecoder: pos += self._porch_samples else: # Scottie: sync + porch between B and R. - # Search for the actual sync pulse to correct per-line - # SDR clock drift — without this, timing errors - # accumulate line-by-line producing a visible slant. + # Use a vectorised fine scan (step=1) so the sync + # pulse is located with single-sample accuracy. + # The coarse _find_sync (step=49) introduced up to + # ±24 sample errors that made colour registration + # worse than no correction at all. # - # Constraints: - # - Backward margin is small (50 samples ≈ 4.5 ms) - # so we don't stray deep into B pixel data. - # - Forward margin is bounded by available buffer so - # the R channel decode never overflows the buffer. - # - The candidate position is validated before use. + # Backward margin: 50 samples (≈ 4.5 ms), enough to + # cover >130 ppm SDR clock error over a full image. + # Forward margin: bounded by available buffer so the + # R channel never overflows. r_samples = self._channel_samples[-1] bwd = min(50, pos) fwd = max(0, len(self._buffer) - pos @@ -252,15 +256,32 @@ class SSTVImageDecoder: - r_samples) fwd = min(fwd, self._sync_samples) if bwd + fwd > 0: + region_start = pos - bwd sync_region = self._buffer[ - pos - bwd: pos + self._sync_samples + fwd] - sync_found = self._find_sync(sync_region) - if sync_found is not None: - candidate = (pos - bwd + sync_found - + self._sync_samples - + self._porch_samples) - if candidate + r_samples <= len(self._buffer): - pos = candidate + region_start: pos + self._sync_samples + fwd] + win = max(20, self._sync_samples // 3) + n_win = len(sync_region) - win + 1 + if n_win > 0: + windows = np.lib.stride_tricks.sliding_window_view( + sync_region, win) + energies = goertzel_batch( + windows, + np.array([FREQ_SYNC, FREQ_BLACK]), + self._sample_rate) + sync_e = energies[:, 0] + black_e = energies[:, 1] + valid = sync_e > black_e * 2 + if valid.any(): + fine_best = int( + np.argmax(np.where(valid, sync_e, 0.0))) + candidate = (region_start + fine_best + + self._sync_samples + + self._porch_samples) + if candidate + r_samples <= len(self._buffer): + pos = candidate + _sync_corrected = True + else: + pos += self._sync_samples + self._porch_samples else: pos += self._sync_samples + self._porch_samples else: @@ -275,8 +296,15 @@ class SSTVImageDecoder: # Martin: porch between channels pos += self._porch_samples - # Advance buffer past this line - consumed = max(pos, self._line_samples) + # Advance buffer past this line. + # For Scottie modes where the mid-line sync was precisely located, + # consume exactly to the end of R so the next line starts at its + # correct separator — this stops per-line timing errors accumulating + # into a slant without overcorrecting into the next line's data. + if _sync_corrected: + consumed = pos + else: + consumed = max(pos, self._line_samples) self._buffer = self._buffer[consumed:] self._current_line += 1