mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 22:59:59 -07:00
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>
326 lines
9.1 KiB
Python
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)
|