mirror of
https://github.com/smittix/intercept.git
synced 2026-07-26 01:38:10 -07:00
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:
@@ -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}"
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user