mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 22:59:59 -07:00
slowrx is a GTK GUI app that doesn't support CLI usage, so the SSTV decoder was silently failing. This replaces it with a pure Python implementation using numpy and Pillow that supports Robot36/72, Martin1/2, Scottie1/2, and PD120/180 modes via VIS header auto-detection. Key implementation details: - Generalized Goertzel (DTFT) for exact-frequency tone detection - Vectorized batch Goertzel for real-time pixel decoding performance - Overlapping analysis windows for short-window frequency estimation - VIS header detection state machine with parity validation - Per-line sync re-synchronization for drift tolerance Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
251 lines
7.1 KiB
Python
251 lines
7.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=93,
|
|
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=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=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,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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,
|
|
PD_120, PD_180,
|
|
]
|
|
}
|
|
|
|
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)
|