fix: Correct SSTV VIS codes and replace Goertzel pixel decoder with Hilbert transform

Fix wrong VIS codes for PD90 (96→99), PD120 (93→95), PD180 (95→97),
PD240 (113→96), and ScottieDX (55→76). This caused PD180 to be detected
as PD90 and PD120 to fail entirely.

Replace batch Goertzel pixel decoding with analytic signal (Hilbert
transform) FM demodulation. The Goertzel approach used 96-sample windows
with ~500 Hz resolution — wider than the 800 Hz pixel frequency range —
making accurate pixel decoding impossible for fast modes like Martin2
and Scottie2. The Hilbert method computes per-sample instantaneous
frequency, matching the approach used by QSSTV and other professional
SSTV decoders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-19 09:23:15 +00:00
parent d2ab2e404a
commit bf71f2a784
4 changed files with 55 additions and 50 deletions
+4 -4
View File
@@ -354,15 +354,15 @@ class TestVISDetector:
assert mode_name == 'Scottie1' assert mode_name == 'Scottie1'
def test_detect_pd120(self): def test_detect_pd120(self):
"""Should detect PD120 VIS code (93).""" """Should detect PD120 VIS code (95)."""
detector = VISDetector() detector = VISDetector()
header = generate_vis_header(93) # PD120 header = generate_vis_header(95) # PD120
audio = np.concatenate([np.zeros(2400), header, np.zeros(2400)]) audio = np.concatenate([np.zeros(2400), header, np.zeros(2400)])
result = detector.feed(audio) result = detector.feed(audio)
assert result is not None assert result is not None
vis_code, mode_name = result vis_code, mode_name = result
assert vis_code == 93 assert vis_code == 95
assert mode_name == 'PD120' assert mode_name == 'PD120'
def test_noise_rejection(self): def test_noise_rejection(self):
@@ -520,7 +520,7 @@ class TestModes:
def test_all_vis_codes_have_modes(self): def test_all_vis_codes_have_modes(self):
"""All defined VIS codes should have matching mode specs.""" """All defined VIS codes should have matching mode specs."""
for vis_code in [8, 12, 44, 40, 60, 56, 93, 95, 96, 98, 113, 55]: for vis_code in [8, 12, 44, 40, 60, 56, 95, 97, 99, 98, 96, 76]:
mode = get_mode(vis_code) mode = get_mode(vis_code)
assert mode is not None, f"No mode for VIS code {vis_code}" assert mode is not None, f"No mode for VIS code {vis_code}"
+5 -5
View File
@@ -59,15 +59,15 @@ VIS_CODES: dict[int, str] = {
40: 'Martin2', 40: 'Martin2',
60: 'Scottie1', 60: 'Scottie1',
56: 'Scottie2', 56: 'Scottie2',
93: 'PD120', 95: 'PD120',
95: 'PD180', 97: 'PD180',
# Less common but recognized # Less common but recognized
4: 'Robot24', 4: 'Robot24',
36: 'Martin3', 36: 'Martin3',
52: 'Scottie3', 52: 'Scottie3',
55: 'ScottieDX', 76: 'ScottieDX',
113: 'PD240', 96: 'PD240',
96: 'PD90', 99: 'PD90',
98: 'PD160', 98: 'PD160',
} }
+41 -36
View File
@@ -20,7 +20,6 @@ from .constants import (
) )
from .dsp import ( from .dsp import (
goertzel, goertzel,
goertzel_batch,
samples_for_duration, samples_for_duration,
) )
from .modes import ( from .modes import (
@@ -98,10 +97,6 @@ class SSTVImageDecoder:
self._channel_data.append( self._channel_data.append(
np.zeros((mode.height, mode.width), dtype=np.uint8)) np.zeros((mode.height, mode.width), dtype=np.uint8))
# Pre-compute candidate frequencies for batch pixel decoding (5 Hz step)
self._freq_candidates = np.arange(
FREQ_PIXEL_LOW - 100, FREQ_PIXEL_HIGH + 105, 5.0)
# Track sync position for re-synchronization # Track sync position for re-synchronization
self._expected_line_start = 0 # Sample offset within buffer self._expected_line_start = 0 # Sample offset within buffer
self._synced = False self._synced = False
@@ -261,18 +256,16 @@ class SSTVImageDecoder:
if self._current_line >= self._total_audio_lines: if self._current_line >= self._total_audio_lines:
self._complete = True self._complete = True
# Minimum analysis window for meaningful Goertzel frequency estimation.
# With 96 samples (2ms at 48kHz), frequency accuracy is within ~25 Hz,
# giving pixel-level accuracy of ~8/255 levels.
_MIN_ANALYSIS_WINDOW = 96
def _decode_channel_pixels(self, audio: np.ndarray) -> np.ndarray: def _decode_channel_pixels(self, audio: np.ndarray) -> np.ndarray:
"""Decode pixel values from a channel's audio data. """Decode pixel values from a channel's audio data.
Uses batch Goertzel to estimate frequencies for all pixels Uses the analytic signal (Hilbert transform via FFT) to compute
simultaneously, then maps to luminance values. When pixels have the instantaneous frequency at every sample, then averages over
fewer samples than ``_MIN_ANALYSIS_WINDOW``, overlapping analysis each pixel's duration. This is the same FM-demodulation approach
windows are used to maintain frequency estimation accuracy. used by QSSTV and other professional SSTV decoders, and provides
far better frequency resolution than windowed Goertzel — especially
for fast modes (Martin2, Scottie2) where each pixel spans only
~11-13 audio samples.
Args: Args:
audio: Audio samples for one channel of one scanline. audio: Audio samples for one channel of one scanline.
@@ -281,36 +274,48 @@ class SSTVImageDecoder:
Array of pixel values (0-255), shape (width,). Array of pixel values (0-255), shape (width,).
""" """
width = self._mode.width width = self._mode.width
samples_per_pixel = max(1, len(audio) // width) n = len(audio)
if len(audio) < width or samples_per_pixel < 2: if n < width:
return np.zeros(width, dtype=np.uint8) return np.zeros(width, dtype=np.uint8)
window_size = max(samples_per_pixel, self._MIN_ANALYSIS_WINDOW) # --- Analytic signal via Hilbert transform (FFT method) ---
spectrum = np.fft.fft(audio)
if window_size > samples_per_pixel and len(audio) >= window_size: # Build the analytic-signal multiplier:
# Use overlapping windows centered on each pixel position # h[0] = 1 (DC), h[1..N/2-1] = 2 (positive freqs),
windows = np.lib.stride_tricks.sliding_window_view( # h[N/2] = 1 (Nyquist), h[N/2+1..] = 0 (negative freqs)
audio, window_size) h = np.zeros(n)
# Pixel centers, clamped to valid window indices if n % 2 == 0:
centers = np.arange(width) * samples_per_pixel h[0] = h[n // 2] = 1
indices = np.minimum(centers, len(windows) - 1) h[1:n // 2] = 2
audio_matrix = np.ascontiguousarray(windows[indices])
else: else:
# Non-overlapping: each pixel has enough samples h[0] = 1
usable = width * samples_per_pixel h[1:(n + 1) // 2] = 2
audio_matrix = audio[:usable].reshape(width, samples_per_pixel)
# Batch Goertzel at all candidate frequencies analytic = np.fft.ifft(spectrum * h)
energies = goertzel_batch(
audio_matrix, self._freq_candidates, self._sample_rate)
# Find peak frequency per pixel # --- Instantaneous frequency ---
best_idx = np.argmax(energies, axis=1) phase = np.unwrap(np.angle(analytic))
best_freqs = self._freq_candidates[best_idx] inst_freq = np.diff(phase) * (self._sample_rate / (2.0 * np.pi))
# Map frequencies to pixel values (1500 Hz = 0, 2300 Hz = 255) # --- Average frequency per pixel ---
normalized = (best_freqs - FREQ_PIXEL_LOW) / (FREQ_PIXEL_HIGH - FREQ_PIXEL_LOW) freq_len = len(inst_freq)
if freq_len < width:
# Fewer freq samples than pixels — index directly
indices = np.linspace(0, freq_len - 1, width).astype(int)
avg_freqs = inst_freq[indices]
else:
pixel_edges = np.linspace(0, freq_len, width + 1).astype(int)
segment_starts = pixel_edges[:-1]
segment_lengths = np.diff(pixel_edges)
segment_lengths = np.maximum(segment_lengths, 1)
sums = np.add.reduceat(inst_freq, segment_starts)
avg_freqs = sums / segment_lengths
# Map to pixel values (1500 Hz → 0, 2300 Hz → 255)
normalized = (avg_freqs - FREQ_PIXEL_LOW) / (
FREQ_PIXEL_HIGH - FREQ_PIXEL_LOW)
return np.clip(normalized * 255 + 0.5, 0, 255).astype(np.uint8) return np.clip(normalized * 255 + 0.5, 0, 255).astype(np.uint8)
def get_image(self) -> Image.Image | None: def get_image(self) -> Image.Image | None:
+5 -5
View File
@@ -189,7 +189,7 @@ SCOTTIE_2 = SSTVMode(
PD_120 = SSTVMode( PD_120 = SSTVMode(
name='PD120', name='PD120',
vis_code=93, vis_code=95,
width=640, width=640,
height=496, height=496,
color_model=ColorModel.YCRCB_DUAL, color_model=ColorModel.YCRCB_DUAL,
@@ -207,7 +207,7 @@ PD_120 = SSTVMode(
PD_180 = SSTVMode( PD_180 = SSTVMode(
name='PD180', name='PD180',
vis_code=95, vis_code=97,
width=640, width=640,
height=496, height=496,
color_model=ColorModel.YCRCB_DUAL, color_model=ColorModel.YCRCB_DUAL,
@@ -225,7 +225,7 @@ PD_180 = SSTVMode(
PD_90 = SSTVMode( PD_90 = SSTVMode(
name='PD90', name='PD90',
vis_code=96, vis_code=99,
width=640, width=640,
height=496, height=496,
color_model=ColorModel.YCRCB_DUAL, color_model=ColorModel.YCRCB_DUAL,
@@ -261,7 +261,7 @@ PD_160 = SSTVMode(
PD_240 = SSTVMode( PD_240 = SSTVMode(
name='PD240', name='PD240',
vis_code=113, vis_code=96,
width=640, width=640,
height=496, height=496,
color_model=ColorModel.YCRCB_DUAL, color_model=ColorModel.YCRCB_DUAL,
@@ -283,7 +283,7 @@ PD_240 = SSTVMode(
SCOTTIE_DX = SSTVMode( SCOTTIE_DX = SSTVMode(
name='ScottieDX', name='ScottieDX',
vis_code=55, vis_code=76,
width=320, width=320,
height=256, height=256,
color_model=ColorModel.RGB, color_model=ColorModel.RGB,