Files
intercept/utils/sstv/modes.py
Smittix 17f6947648 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>
2026-02-19 09:23:15 +00:00

326 lines
9.1 KiB
Python

"""SSTV mode specifications.
Dataclass definitions for each supported SSTV mode, encoding resolution,
color model, line timing, and sync characteristics.
"""
from __future__ import annotations
import enum
from dataclasses import dataclass, field
class ColorModel(enum.Enum):
"""Color encoding models used by SSTV modes."""
RGB = 'rgb' # Sequential R, G, B channels per line
YCRCB = 'ycrcb' # Luminance + chrominance (Robot modes)
YCRCB_DUAL = 'ycrcb_dual' # Dual-luminance YCrCb (PD modes)
class SyncPosition(enum.Enum):
"""Where the horizontal sync pulse appears in each line."""
FRONT = 'front' # Sync at start of line (Robot, Martin)
MIDDLE = 'middle' # Sync between G and B channels (Scottie)
FRONT_PD = 'front_pd' # PD-style sync at start
@dataclass(frozen=True)
class ChannelTiming:
"""Timing for a single color channel within a scanline.
Attributes:
duration_ms: Duration of this channel's pixel data in milliseconds.
"""
duration_ms: float
@dataclass(frozen=True)
class SSTVMode:
"""Complete specification of an SSTV mode.
Attributes:
name: Human-readable mode name (e.g. 'Robot36').
vis_code: VIS code that identifies this mode.
width: Image width in pixels.
height: Image height in lines.
color_model: Color encoding model.
sync_position: Where the sync pulse falls in each line.
sync_duration_ms: Horizontal sync pulse duration (ms).
sync_porch_ms: Porch (gap) after sync pulse (ms).
channels: Timing for each color channel per line.
line_duration_ms: Total duration of one complete scanline (ms).
has_half_rate_chroma: Whether chroma is sent at half vertical rate
(Robot modes: Cr and Cb alternate every other line).
"""
name: str
vis_code: int
width: int
height: int
color_model: ColorModel
sync_position: SyncPosition
sync_duration_ms: float
sync_porch_ms: float
channels: list[ChannelTiming] = field(default_factory=list)
line_duration_ms: float = 0.0
has_half_rate_chroma: bool = False
channel_separator_ms: float = 0.0 # Time gap between color channels (ms)
# ---------------------------------------------------------------------------
# Robot family
# ---------------------------------------------------------------------------
ROBOT_36 = SSTVMode(
name='Robot36',
vis_code=8,
width=320,
height=240,
color_model=ColorModel.YCRCB,
sync_position=SyncPosition.FRONT,
sync_duration_ms=9.0,
sync_porch_ms=3.0,
channels=[
ChannelTiming(duration_ms=88.0), # Y (luminance)
ChannelTiming(duration_ms=44.0), # Cr or Cb (alternating)
],
line_duration_ms=150.0,
has_half_rate_chroma=True,
channel_separator_ms=6.0,
)
ROBOT_72 = SSTVMode(
name='Robot72',
vis_code=12,
width=320,
height=240,
color_model=ColorModel.YCRCB,
sync_position=SyncPosition.FRONT,
sync_duration_ms=9.0,
sync_porch_ms=3.0,
channels=[
ChannelTiming(duration_ms=138.0), # Y (luminance)
ChannelTiming(duration_ms=69.0), # Cr
ChannelTiming(duration_ms=69.0), # Cb
],
line_duration_ms=300.0,
has_half_rate_chroma=False,
channel_separator_ms=6.0,
)
# ---------------------------------------------------------------------------
# Martin family
# ---------------------------------------------------------------------------
MARTIN_1 = SSTVMode(
name='Martin1',
vis_code=44,
width=320,
height=256,
color_model=ColorModel.RGB,
sync_position=SyncPosition.FRONT,
sync_duration_ms=4.862,
sync_porch_ms=0.572,
channels=[
ChannelTiming(duration_ms=146.432), # Green
ChannelTiming(duration_ms=146.432), # Blue
ChannelTiming(duration_ms=146.432), # Red
],
line_duration_ms=446.446,
)
MARTIN_2 = SSTVMode(
name='Martin2',
vis_code=40,
width=320,
height=256,
color_model=ColorModel.RGB,
sync_position=SyncPosition.FRONT,
sync_duration_ms=4.862,
sync_porch_ms=0.572,
channels=[
ChannelTiming(duration_ms=73.216), # Green
ChannelTiming(duration_ms=73.216), # Blue
ChannelTiming(duration_ms=73.216), # Red
],
line_duration_ms=226.798,
)
# ---------------------------------------------------------------------------
# Scottie family
# ---------------------------------------------------------------------------
SCOTTIE_1 = SSTVMode(
name='Scottie1',
vis_code=60,
width=320,
height=256,
color_model=ColorModel.RGB,
sync_position=SyncPosition.MIDDLE,
sync_duration_ms=9.0,
sync_porch_ms=1.5,
channels=[
ChannelTiming(duration_ms=138.240), # Green
ChannelTiming(duration_ms=138.240), # Blue
ChannelTiming(duration_ms=138.240), # Red
],
line_duration_ms=428.220,
)
SCOTTIE_2 = SSTVMode(
name='Scottie2',
vis_code=56,
width=320,
height=256,
color_model=ColorModel.RGB,
sync_position=SyncPosition.MIDDLE,
sync_duration_ms=9.0,
sync_porch_ms=1.5,
channels=[
ChannelTiming(duration_ms=88.064), # Green
ChannelTiming(duration_ms=88.064), # Blue
ChannelTiming(duration_ms=88.064), # Red
],
line_duration_ms=277.692,
)
# ---------------------------------------------------------------------------
# PD (Pasokon) family
# ---------------------------------------------------------------------------
PD_120 = SSTVMode(
name='PD120',
vis_code=95,
width=640,
height=496,
color_model=ColorModel.YCRCB_DUAL,
sync_position=SyncPosition.FRONT_PD,
sync_duration_ms=20.0,
sync_porch_ms=2.080,
channels=[
ChannelTiming(duration_ms=121.600), # Y1 (even line luminance)
ChannelTiming(duration_ms=121.600), # Cr
ChannelTiming(duration_ms=121.600), # Cb
ChannelTiming(duration_ms=121.600), # Y2 (odd line luminance)
],
line_duration_ms=508.480,
)
PD_180 = SSTVMode(
name='PD180',
vis_code=97,
width=640,
height=496,
color_model=ColorModel.YCRCB_DUAL,
sync_position=SyncPosition.FRONT_PD,
sync_duration_ms=20.0,
sync_porch_ms=2.080,
channels=[
ChannelTiming(duration_ms=183.040), # Y1
ChannelTiming(duration_ms=183.040), # Cr
ChannelTiming(duration_ms=183.040), # Cb
ChannelTiming(duration_ms=183.040), # Y2
],
line_duration_ms=754.240,
)
PD_90 = SSTVMode(
name='PD90',
vis_code=99,
width=640,
height=496,
color_model=ColorModel.YCRCB_DUAL,
sync_position=SyncPosition.FRONT_PD,
sync_duration_ms=20.0,
sync_porch_ms=2.080,
channels=[
ChannelTiming(duration_ms=91.520), # Y1
ChannelTiming(duration_ms=91.520), # Cr
ChannelTiming(duration_ms=91.520), # Cb
ChannelTiming(duration_ms=91.520), # Y2
],
line_duration_ms=388.160,
)
PD_160 = SSTVMode(
name='PD160',
vis_code=98,
width=640,
height=496,
color_model=ColorModel.YCRCB_DUAL,
sync_position=SyncPosition.FRONT_PD,
sync_duration_ms=20.0,
sync_porch_ms=2.080,
channels=[
ChannelTiming(duration_ms=152.960), # Y1
ChannelTiming(duration_ms=152.960), # Cr
ChannelTiming(duration_ms=152.960), # Cb
ChannelTiming(duration_ms=152.960), # Y2
],
line_duration_ms=633.920,
)
PD_240 = SSTVMode(
name='PD240',
vis_code=96,
width=640,
height=496,
color_model=ColorModel.YCRCB_DUAL,
sync_position=SyncPosition.FRONT_PD,
sync_duration_ms=20.0,
sync_porch_ms=2.080,
channels=[
ChannelTiming(duration_ms=244.480), # Y1
ChannelTiming(duration_ms=244.480), # Cr
ChannelTiming(duration_ms=244.480), # Cb
ChannelTiming(duration_ms=244.480), # Y2
],
line_duration_ms=1000.000,
)
# ---------------------------------------------------------------------------
# Scottie DX
# ---------------------------------------------------------------------------
SCOTTIE_DX = SSTVMode(
name='ScottieDX',
vis_code=76,
width=320,
height=256,
color_model=ColorModel.RGB,
sync_position=SyncPosition.MIDDLE,
sync_duration_ms=9.0,
sync_porch_ms=1.5,
channels=[
ChannelTiming(duration_ms=345.600), # Green
ChannelTiming(duration_ms=345.600), # Blue
ChannelTiming(duration_ms=345.600), # Red
],
line_duration_ms=1050.300,
)
# ---------------------------------------------------------------------------
# Mode registry
# ---------------------------------------------------------------------------
ALL_MODES: dict[int, SSTVMode] = {
m.vis_code: m for m in [
ROBOT_36, ROBOT_72,
MARTIN_1, MARTIN_2,
SCOTTIE_1, SCOTTIE_2, SCOTTIE_DX,
PD_90, PD_120, PD_160, PD_180, PD_240,
]
}
MODE_BY_NAME: dict[str, SSTVMode] = {m.name: m for m in ALL_MODES.values()}
def get_mode(vis_code: int) -> SSTVMode | None:
"""Look up an SSTV mode by its VIS code."""
return ALL_MODES.get(vis_code)
def get_mode_by_name(name: str) -> SSTVMode | None:
"""Look up an SSTV mode by name."""
return MODE_BY_NAME.get(name)