
SSTV (slow-scan television) — телевидение с медленной разверткой, узкополосный формат передачи данных, позволяющий передавать изображения через эфир. В этой статье будут рассмотрены подробности кодирования, декодирования SSTV-сигнала.
Статья может быть интересна радиолюбителям, желающим познакомиться с новым форматом связи, а также тем, кто хочет в подробностях понять как же работает эта технология.
Введение
Телевидение — технология передачи визуальной информации посредством радиосвязи, включающая в себя преобразование изображения в сигнал, его передачу в эфире и последующее декодирование с восстановлением исходного изображения на принимающем устройстве.
Технология SSTV базируется на схожих с классическим телесигналом принципах.
Телевидение, как таковое, подразумевает, что происходит передача изображения; в частности в обычном телесигнале изображение передается покадрово, по 25 кадров в секунду.
Каждый кадр, в свою очередь, передается построчно. В строках содержится информация о яркости цветовых каналов (в частности яркость и цветоразностный сигнал при передаче YCbCr).
Для передачи всей этой информации требуется достаточно широкая полоса пропускания сигнала, а также высокочастотное декодирование. К примеру, для SECAM необходима полоса шириной 6.5 МГц.
Отличительной особенностью SSTV является то, что используется относительно узкая полоса пропускания, в единицы килогерц, что в свою очередь может передаваться в слышимом для человека диапазоне частот. И, как следствие использования узкополосной передачи, является скорость передачи кадра, которая может варьироваться от десятков секунд, до нескольких минут на один кадр. По этой причине SSTV используется для передачи одиночных изображений.
Необходимо упомянуть, что технология SSTV использовалась на заре освоения космоса, когда технологии еще не позволяли передавать и обрабатывать широкополосные сигналы; например фотография обратной (темной) стороны Луны была передана посредством телевидения с медленной разверткой.
С тех пор принципы SSTV не претерпели фундаментальных изменений, изменился формат и способы кодирования строк и цветности.
На момент написания статьи технология активно используется радиолюбителями.
Области применения
SSTV в основном используется радиолюбителями, когда романтика телеграфной и телефонной передачи надоедает и хочется попробовать чего-то нового и необычного; например попытаться увидеться с корреспондентом, находящегося за тысячи километров по ту сторону радиоэфира, либо же отправить ему через эфир свою QSL-карточку (открытка с рапортом, диапазоном и позывными, подтверждающая факт проведения связи в эфире). Также популярен прием изображений с Международной Космической Станции на диапазоне 2м (144 МГц); передачи, как правило, приурочены к праздничным и памятным событиям.
Также, технология SSTV может быть использована как средство передачи данных последнего шанса, в экстренных ситуациях, когда, например, необходимо передать карты, схемы, фотографии, и т.д., а использование других каналов связи сильно затруднено и/или невозможно.
Принципы работы SSTV
В основе формирования SSTV сигнала лежит использование FSK (Frequency Shift Keying) — частотной манипуляции. Данные определяются тоном (частотой) сигнала, меняющегося во времени; при этом частотная манипуляция обладает свойствами помехоустойчивой, т.к. помехи влияют на амплитуду несущего сигнала, а не на его частоту.
Кодирование цвета
При кодировании изображения, последнее разбивается на отдельные цветовые каналы яркости цвета или цветоразностного сигнала. Далее, в зависимости от яркости канала формируется тон, высота которого пропорционально яркости канала; чем ярче — тем выше тон.
В SSTV яркость канала разбивается на 255 значений, соответственно одна цветовая строка будет содержать в себе тональности с шагом, кратным 1/255. Например широкополосный формат SSTV имеет диапазон тонов от 1.5 КГц до 2.3 КГц, таким образом, получается диапазон в 800 Гц (2300 — 1500 = 800) и 3.137 Гц (800 / 255 = 3.137) на одну ступень яркости.
В случае узкополосных форматов, диапазон частот составляет 256 Гц (от 2.044 КГц до 2.3 КГц), соответственно одному шагу яркости соответствует 1 Гц.

Рисунок 1: Соотношение частоты яркости сигнала.
На рисунке 1 приведен наглядный пример соотношения частот и яркости канала.
Структура сигнала
Сигнал SSTV состоит из:
заголовка, по которому приемник определяет начало и формат передаваемого сигнала, относительно которого будет происходить декодирование строк;
импульса синхронизации строки;
последовательно закодированных строк изображения.
Заголовок сигнала
Заголовок сигнала в свою очередь состоит из серии калибровочных импульсов и VIS-кода.

Рисунок 2: Структура калибровочных импульсов.
Сигнал VIS (Vertical Interval Signaling) включает в себя стартовый импульс, кодовые биты, определяющие формат, завершающийся стоп-битом.
На рисунке 3 приведена структура VIS-сигнала.

Рисунок 3: Структура VIS-кода.
Как видно из рисунка, сигнал формируется сначала из двух тонов 1.9 КГц, длительностью по 0.3 сек, разделенные тоном в 1.2 КГц, длительностью 0.01 сек.
Далее кодируются последовательность из 8 бит, определяющая формат видеосигнала. Нулю соответствует тон в 1.3 КГц, единице 1.1 КГц.
В зависимости от типа сигнала, узкополосный или широкополосный, длительность бита равняется 0.022 сек и 0.03 сек соответственно.
Биты VIS-кода определяют тип формата и его свойства
Таблица 1: Расшифровка VIS-кода.
MSB |
LSB |
Значение |
||||||
P |
6 |
5 |
4 |
3 |
2 |
1 |
0 |
|
0 |
0 |
Цветное видео |
||||||
0 |
1 |
ЧБ, красный канал |
||||||
1 |
0 |
ЧБ, зеленый канал |
||||||
1 |
1 |
ЧБ, синий канал |
||||||
0 |
Ширина кадра 128/160 пикселей |
|||||||
1 |
Ширина кадра 256/320 пикселей |
|||||||
0 |
Высота кадра 128/120 строк |
|||||||
1 |
Высота кадра 256/240 строк |
|||||||
0 |
0 |
0 |
Признак формата Robot |
|||||
1 |
0 |
0 |
Признак форматов AVT, Scottie DX |
|||||
1 |
1 |
Признак формата PD |
||||||
* |
Бит четности |
Первые 4 бита определяют цветовые свойства формата, следующие 3 бита унифицируют формат.
Последний 8-й бит — бит четности, который принимает значение 1, если количество информационных бит нечетное.
На данный момент предел в 8 бит уже практически исчерпан и в некоторых форматах этот код расширен до 16 бит.
Примеры кодов некоторых форматов:
Martin 1: 00101100
Robot 36: 00001000
PD50: 01011101
Scottie DX: 01001100
Примеры 16-и битных кодов:
MMSSTV ML180: 0000010100100011
MMSSTV MP320: 0000101000100011
Кодирование строк
При кодировании строк, исходное изображение раскладывается на каналы цветности.
У черно-белого изображения — это канал яркости. Цветные изображения состоят из компонентов красного, зеленого и синих цветов (RGB), либо из яркости и цветоразностных составляющих по синему и красному цветам.
В случае передачи изображения в формате RGB последовательно передается каждый цветовой канал (могут передаваться как в порядке R, G, B, так и B, G, R).
В зависимости от формата, под каждый канал может быть определено разное время передачи. Так, например, в формате Robot выделено вдвое меньшее время, чем под канал яркости; таким образом, при потере цветности, изображение будет черно-белым.

Рисунок 4: Общий вид кодирования строки формата Robot.
Определение начала строки осуществляется добавлением в сигнал импульсов синхронизации. В большинстве форматов импульс синхронизации добавляется перед началом строки.
Каналы цветности также могут быть отделены импульсами синхронизации. Так, например, на рисунке 4 показана структура формата Robot, между каналами Y и Cb (и между каналами Cb и Cr) расположен сигнал длительностью 4 миллисекунды.
Каждый формат кодирования изображения может иметь свой порядок синхронизации строк и каналов; например в семействе форматов Martin каналы передаются в формате GBR, при этом каждый канал отделен от другого импульсом; в семействе MMSSTV каналы передаются неразрывно, а формат AVT вообще не имеет синхроимпульсов в строке.
Узкополосный SSTV
Узкополосные (narrow) форматы SSTV отличаются от широкополосных (wide) тем, что имеют полосу частот для передачи изображения в 256 Гц (от 2.04 КГц до 2.3 КГц).

Рисунок 5: Заголовок узкополосного SSTV.
Перед передачей VIS-кода формируется заголовок длительностью 400 миллисекунд, содержащий в себе 2 пары тонов в 1.9 КГц и 2.3 КГц, длительностью 100 миллисекунд каждый. По этим сигналам на принимающей стороне происходит определение типа передачи.
Следующей особенностью узкополосного формата является способ формирования VIS-кода. Код записывается 4-я группами по 6 бит каждая. Кодируемые значения представлены в таблице 2. Дополнительная информация о свойствах формата в биты VIS-кода не вносится.
Таблица 2: группы данных VIS-кода узкополосного SSTV
Группа |
Биты |
1 |
101101 |
2 |
010101 |
3 |
код_формата |
4 |
010101 xor код_формата |
VIS-код для узкополосного формата MMSSTV MP73-N (идентификатор формата равен значению 2) имеет вид 101101-010101-000010-010111
В остальном принципы передачи изображения идентичны широкополосным SSTV.
Форматы SSTV
Перечень наиболее распространенных широкополосных SSTV-форматов используемых в любительской радиосвязи.
Таблица 3: Wide SSTV форматы
Формат |
Каналы |
Время кадра |
Высота |
Ширина |
Amiga Video Transceiver 90 |
RGB |
98 |
240 |
320 |
Martin 1 |
GBR |
114 |
256 |
320 |
Martin 2 |
GBR |
58 |
256 |
320 |
MMSSTV MR73 |
YCbCr |
73 |
256 |
320 |
MMSSTV MR90 |
YCbCr |
90 |
256 |
320 |
MMSSTV MR115 |
YCbCr |
115 |
256 |
320 |
MMSSTV MR140 |
YCbCr |
140 |
256 |
320 |
MMSSTV MR175 |
YCbCr |
175 |
256 |
320 |
MMSSTV MP73 |
YCbCr |
73 |
256 |
320 |
MMSSTV MP115 |
YCbCr |
115 |
256 |
320 |
MMSSTV MP140 |
YCbCr |
140 |
256 |
320 |
MMSSTV MP175 |
YCbCr |
175 |
256 |
320 |
MMSSTV ML180 |
YCbCr |
180 |
256 |
320 |
MMSSTV ML240 |
YCbCr |
240 |
256 |
320 |
MMSSTV ML280 |
YCbCr |
280 |
256 |
320 |
MMSSTV ML320 |
YCbCr |
320 |
256 |
320 |
P3 |
RGB |
203 |
496 |
640 |
P5 |
RGB |
305 |
496 |
640 |
P7 |
RGB |
406 |
496 |
640 |
PD50 |
YCbCr |
50 |
256 |
320 |
PD90 |
YCbCr |
90 |
256 |
320 |
PD120 |
YCbCr |
126 |
496 |
640 |
PD160 |
YCbCr |
161 |
400 |
512 |
PD180 |
YCbCr |
187 |
496 |
640 |
PD240 |
YCbCr |
248 |
496 |
640 |
PD290 |
YCbCr |
289 |
616 |
800 |
Robot 24 |
YCbCr |
24 |
240 |
320 |
Robot 36 |
YCbCr |
36 |
240 |
320 |
Robot 72 |
YCbCr |
72 |
240 |
320 |
Robot B&W 8 |
BW |
8 |
120 |
160 |
Robot B&W 12 |
BW |
12 |
120 |
160 |
SC2 60 |
RGB |
62 |
256 |
320 |
SC2 120 |
RGB |
122 |
256 |
320 |
SC2 180 |
RGB |
182 |
256 |
320 |
Scottie 1 |
GBR |
110 |
256 |
320 |
Scottie 2 |
GBR |
71 |
256 |
320 |
Scottie DX |
GBR |
269 |
256 |
320 |
Перечень наиболее распространенных узкополосных SSTV-форматов используемых в любительской радиосвязи.
Таблица 4: Wide SSTV форматы
Формат |
Каналы |
Время кадра |
Высота |
Ширина |
MMSSTV MP73-N |
YCbCr |
73 |
256 |
320 |
MMSSTV MP110-N |
YCbCr |
110 |
256 |
320 |
MMSSTV MP140-N |
YCbCr |
140 |
256 |
320 |
MMSSTV MC110-N |
RGB |
110 |
256 |
320 |
MMSSTV MC140-N |
RGB |
140 |
256 |
320 |
MMSSTV MC180-N |
RGB |
180 |
256 |
320 |
Как видно из таблиц 3 и 4, в большинстве форматов числовое значение в имени кода соответствует времени передачи кадра или очень близкое к нему.
Кодирование SSTV сигнала
Пример кодирования изображения в SSTV сигнал на языке python.
Для работы примера потребуются библиотеки:
numpy~=2.2.3
pillow~=11.1.0
scipy~=1.15.2
Установка через pip:
pip install numpy~=2.2.3 pillow~=11.1.0 scipy~=1.15.2
В качестве примера взят формат Robot 72.
Параметры формата:
Ширина полосы: широкополосный (1.5-2.3 КГц)
Количество строк: 240
Размер строки: 320
Цветопередача: YCbCr
Время кадра: 72 секунды
Импорт:
import math
import statistics
import typing
from itertools import chain
import numpy as np
from PIL import Image
from scipy.io.wavfile import write
Объявление типов (опционально):
Signal = typing.List[float]
SignalGen = typing.Generator[float, None, None]
Color = int
Tone = typing.NamedTuple("Tone", [("freq", typing.Union[int, typing.Tuple[int, int]]), ("time", float)])
Channel = typing.NamedTuple("Channel", [("id", typing.Union[int, typing.Tuple[int, int]]), ("time", float)])
Частота дискретизации выходного звукового файла:
SAMPLE_RATE = 11025
Вспомогательные функции:
def yield_tones(tones) -> SignalGen:
for tone in tones:
yield (tone.freq, tone.time)
FREQ_LOW = 1500
FREQ_HIGH = 2300
def color_to_freq(color: Color) -> float:
return color * (FREQ_HIGH - FREQ_LOW) / 255 + FREQ_LOW
FREQ_LOW
— нижняя частота кодирования цвета, FREQ_HIGH
— верхняя соответственно.
Параметр color
— значение яркости цветового канала, лежащее в диапазоне от 0 до 255.
Определение SSTV-заголовка:
HEADER_WIDE = [
Tone(1900, 0.100000),
Tone(1500, 0.100000),
Tone(1900, 0.100000),
Tone(1500, 0.100000),
Tone(2300, 0.100000),
Tone(1500, 0.100000),
Tone(2300, 0.100000),
Tone(1500, 0.100000),
]
def encode_header() -> SignalGen:
yield from yield_tones(HEADER_WIDE)
HEADER_WIDE
— последовательность тонов, согласно спецификации к формату, где первым параметром задается частота в герцах, а вторым — продолжительность сигнала в секундах (значение 0.100000 соответствует 100 миллисекундам).
Определение VIS-кода и калибровочного сигнала:
VIS_CODE = 12
BIT_1_WIDE_FREQ = 1100
BIT_0_WIDE_FREQ = 1300
VIS_WIDE_BIT_SIZE = 0.030000
VIS_BIT_TONE_WIDE = Tone((BIT_0_WIDE_FREQ, BIT_1_WIDE_FREQ), VIS_WIDE_BIT_SIZE)
VIS_BIT_TONE_MEDIAN_WIDE = Tone(statistics.median([BIT_0_WIDE_FREQ, BIT_1_WIDE_FREQ]), VIS_WIDE_BIT_SIZE)
CALIBRATION_WIDE = [
Tone(1900, 0.300000),
Tone(1200, 0.010000),
Tone(1900, 0.300000),
Tone(1200, 0.030000),
]
def encode_vis() -> SignalGen:
fsk_len = 8
vis = VIS_CODE
vis |= (vis.bit_count() & 1) << fsk_len - 1 # Parity bit
yield from yield_tones(CALIBRATION_WIDE)
value = vis
for _ in range(fsk_len):
yield (VIS_BIT_TONE_WIDE.freq[value & 1], VIS_BIT_TONE_WIDE.time)
value >>= 1
yield (VIS_BIT_TONE_MEDIAN_WIDE.freq, VIS_BIT_TONE_MEDIAN_WIDE.time)
Формату Robot 72 соответствует VIS-код со значением 12; количество бит, необходимых для кодирования VIS-кода, составляет 8 бит (значение переменной fsk_len
).
Для кодирования бит используются частоты 1.1 КГц для логических единиц и 1.3 КГц для нулей соответственно (значения BIT_1_WIDE_FREQ
, BIT_0_WIDE_FREQ
).
Функция encode_vis
определяет количество бит в VIS-коде и выравнивает четность исходного числа, выставляя 8 бит.
VIS_BIT_TONE_MEDIAN_WIDE
— сигнал завершения блока с VIS-кодом.
Определение параметров кодирования изображения
COLOR = "YCbCr"
LINE_WIDTH = 320
LINE_COUNT = 240
Формат Robot 72 использует цветоразностное кодирование. Размер кадра 320*240. Эти значения используются для кадрирования исходного изображения, а также для получения значения цветов каналов согласно цветовой схеме из COLOR
.
Определение продолжительности импульсов синхронизации и длительности импульсов цветопередачи:
SCAN_TIME = 0.138000
PIXEL_TIME = SCAN_TIME / LINE_WIDTH
HALF_SCAN_TIME = SCAN_TIME / 2
HALF_PIXEL_TIME = HALF_SCAN_TIME / LINE_WIDTH
SYNC_PULSE = 0.009000
SYNC_PORCH = 0.003000
SEP_PULSE = 0.004500
SEP_PORCH = 0.001500
SCAN_TIME
— длительность одной строки изображения. Исходя из этого определяется длительность сигнала на один пиксел изображения. Т.к. у формата Robot 72 значения обоих цветоразностных сигналов передаются за то же время, за которое передается строка с яркостью, необходимо определить половину времени на пиксел.
Значения длительности импульсов синхронизации и импульсов-разделителей взяты из спецификации к формату.
Определение структуры кодирования формата:
TIMING_SEQUENCE = [
Tone(1200, SYNC_PULSE),
Tone(1500, SYNC_PORCH),
Channel(0, PIXEL_TIME),
Tone(1500, SEP_PULSE),
Tone(1900, SEP_PORCH),
Channel(1, HALF_PIXEL_TIME),
Tone(1500, SEP_PULSE),
Tone(1900, SEP_PORCH),
Channel(2, HALF_PIXEL_TIME),
]
Список TIMING_SEQUENCE
описывает последовательность кодирования данных одной строки изображения. Перед кодированием первого канала записываются сигналы синхронизации (1.2 и 1.5 КГц). Перед последующими каналами записываются импульсы-разделители (1.5 и 1.9 КГц). Как можно заметить, для последующих каналов длительность пикселов определяется значением HALF_PIXEL_TIME
.
Определение генераторной функции, переводящей изображение в последовательность тонов, согласно TIMING_SEQUENCE
:
def encode_image_data(image) -> SignalGen:
height = LINE_COUNT
width = LINE_WIDTH
pixels = image.convert(COLOR).resize((width, height), Image.Resampling.LANCZOS).load()
y = 0
while y < height:
odd_line = y % 2
for tone in TIMING_SEQUENCE:
if isinstance(tone, Tone):
yield (freq, tone.time)
elif isinstance(tone, Channel):
for px in range(width):
pixel = pixels[px, y] # RGB order
pixel = (pixel[0], pixel[2], pixel[1]) # YUV order
yield (color_to_freq(pixel[_id]), tone.time)
y += 1
Функция encode_image_data
последовательно обходит по строкам в изображении и выполняет перевод каждого пиксела в строке согласно TIMING_SEQUENCE
.
Если в TIMING_SEQUENCE
указан импульс синхронизации, то выдаются параметры этого импульса. Если же в TIMING_SEQUENCE
указан цветовой канал, тогда согласно параметрам извлекается уровень канала, происходит определение частоты согласно значению уровню канала, далее формируется соответствующий тон-сигнал заданной продолжительности.
Определение функции преобразования изображения в набор сэмплов, готовых к записи в wav-файл:
def encode(image) -> SignalGen:
spms = SAMPLE_RATE / 1000
offset = 0
samples = 0
factor = math.pi * 2 / SAMPLE_RATE # math.tau -- 2pi
sample = 0
generators = chain(
encode_header(),
encode_vis(),
encode_image_data(image),
)
for freq, sec in generators:
samples += spms * sec * 1000
tx = int(samples)
freq_factor = freq * factor
for sample in range(tx):
yield math.sin(math.fmod(sample * freq_factor + offset, math.tau))
offset += (sample + 1) * freq_factor
samples -= tx
В функции encode формируется сигнал на основе функции синуса (sin
), происходит расчет фазы и выдается последовательность дискретных значений от -1 до 1 с частотой дискретизации SAMPLE_RATE
.
Открытие файла с изображением и сохранение результирующего сигнала в wav-файл:
with Image.open("color-bars.png") as im:
amplitude = np.iinfo(np.int16).max
tones = np.fromiter(encode(im), dtype=np.float32) * amplitude
write("robot72-example.wav", SAMPLE_RATE, tones.astype(np.int16))
Исходное изображение из файла color-bars.png
и запись в целевой файл с именем robot72-example.wav
.
В качестве тестового изображения можно воспользоваться настроечной ТВ-таблицей:

Рисунок 6: Настроечная телевизионная таблица.
Спектрограмма получившегося аудиофайла (общий вид):

Рисунок 7: Общий вид спектра сигнала.
На общей спектрограмме можно заметить изменения в спектре, которые соответствуют резкой смене изображения, где у цветных столбцов инвертируется порядок цветов и в конце, где они переходят в столбики другой ширины, и цвета.
Спектрограмма начала файла:

Рисунок 8: Заголовок SSTV, VIS-код и начальные строки SSTV-сигнала.
Здесь можно увидеть ярко выраженные тона синхронизации длительностью 0.1 сек, импульсы калибровки и биты VIS-кода. Далее идут тона, соответствующие данным цветовых каналов.
Спектрограмма каналов:

Рисунок 9: Спектр первых строк SSTV-сигнала.
Каналы с цветоразностными значениями по времени в два раза короче, чем канал яркости. Также на спектрограмме видно импульсы синхронизации.
Декодирование SSTV сигнала
Импорт:
import statistics
import typing
from functools import reduce
import numpy as np
from PIL import Image
from scipy.io.wavfile import read
from scipy.signal.windows import hann
Определение типов:
Signal = typing.List[float]
SignalGen = typing.Generator[float, None, None]
Color = int
Bit = typing.Literal[0, 1]
BitGen = typing.Generator[Bit, None, None]
Tone = typing.NamedTuple("Tone", [("freq", typing.Union[int, typing.Tuple[int, int]]), ("time", float)])
ToneSlice = typing.Tuple[slice, float]
ToneSlices = typing.List[ToneSlice]
Channel = typing.NamedTuple("Channel", [("id", typing.Union[int, typing.Tuple[int, int]]), ("time", float)])
Вспомогательные функции для работы с сигналом:
def bits_to_int(bits: typing.List[Bit]) -> int:
return reduce(lambda value, bit: (value << 1) | (bit & 1), bits[::-1])
def barycentric_peak_interp(bins, x):
y1 = bins[x] if x <= 0 else bins[x - 1]
y3 = bins[x] if x + 1 >= len(bins) else bins[x + 1]
denom = y3 + bins[x] + y1
if denom == 0:
return 0
return (y3 - y1) / denom + x
def peak_fft_freq(signal: Signal, sample_rate: float) -> float:
windowed_data = signal * hann(len(signal))
fft = np.abs(np.fft.rfft(windowed_data))
# Get index of bin with the highest magnitude
x = np.argmax(fft)
# Interpolated peak frequency
peak = barycentric_peak_interp(fft, x)
# Return frequency in hz
return peak * sample_rate / len(windowed_data)
bits_to_int
— функция свертки списка битов в число;
barycentric_peak_interp
— функция интерполяции барицентрическим полиномом;
peak_fft_freq
— функция определения частоты с максимальной амплитудой в отрезке сигнала (используется преобразование Фурье с оконной функцией Хеннинга).
Определение параметров формата Robot 72:
SAMPLE_RATE = None
FREQ_LOW = 1500
FREQ_HIGH = 2300
FREQ_SYNC_PULSE = 1200
FREQ_SYNC_PORCH = 1500
FREQ_SYNC_MEDIAN = statistics.median([FREQ_SYNC_PULSE, FREQ_SYNC_PORCH])
WINDOW_FACTOR = 4.88
VIS_CODE = 12
BIT_1_WIDE_FREQ = 1100
BIT_0_WIDE_FREQ = 1300
VIS_WIDE_BIT_SIZE = 0.030000
VIS_BIT_TONE_WIDE = Tone((BIT_0_WIDE_FREQ, BIT_1_WIDE_FREQ), VIS_WIDE_BIT_SIZE)
VIS_BIT_TONE_MEDIAN_WIDE = Tone(statistics.median([BIT_0_WIDE_FREQ, BIT_1_WIDE_FREQ]), VIS_WIDE_BIT_SIZE)
CALIBRATION_WIDE = [
Tone(1900, 0.300000),
Tone(1200, 0.010000),
Tone(1900, 0.300000),
Tone(1200, 0.030000),
]
COLOR = "YCbCr"
LINE_WIDTH = 320
LINE_COUNT = 240
SCAN_TIME = 0.138000
PIXEL_TIME = SCAN_TIME / LINE_WIDTH
HALF_SCAN_TIME = SCAN_TIME / 2
HALF_PIXEL_TIME = HALF_SCAN_TIME / LINE_WIDTH
SYNC_PULSE = 0.009000
SYNC_PORCH = 0.003000
SEP_PULSE = 0.004500
SEP_PORCH = 0.001500
CHAN_TIME = SEP_PULSE + SCAN_TIME
HALF_CHAN_TIME = SEP_PULSE + HALF_SCAN_TIME
CHANNELS = 3
CHAN_SYNC = 0
CHAN_OFFSETS = [SYNC_PULSE + SYNC_PORCH]
CHAN_OFFSETS.append(CHAN_OFFSETS[0] + CHAN_TIME + SEP_PORCH)
CHAN_OFFSETS.append(CHAN_OFFSETS[1] + HALF_CHAN_TIME + SEP_PORCH)
LINE_TIME = CHAN_OFFSETS[2] + HALF_SCAN_TIME
Вспомогательные функции декодирования сигналов:
def freq_to_color(freq: float) -> Color:
lum = int(round((freq - FREQ_LOW) / ((FREQ_HIGH - FREQ_LOW) / 255)))
return min(max(lum, 0), 255)
def read_bits(signal: Signal,
bit_count: int, bit_time: float,
freq_true: float, freq_false: float,
sample_rate: float,
offset: int = 0) -> BitGen:
bit_threshold = statistics.median([freq_true, freq_false])
bit_size = round(bit_time * sample_rate)
for bit_idx in range(bit_count):
bit_offset = offset + bit_idx * bit_size
section = signal[bit_offset:bit_offset + bit_size]
freq = peak_fft_freq(section, sample_rate)
yield int(freq <= bit_threshold)
def tones_to_slices(tones: typing.Iterable[Tone],
sample_rate: float,
window_size: typing.Optional[float] = None) -> typing.Tuple[int, ToneSlices]:
# The margin of error created here will be negligible when decoding the
# vis due to each bit having a length of 30ms. We fix this error margin
# when decoding the image by aligning each sync pulse
slices = []
time_acc = 0
for it in tones:
area = slice(
round(time_acc * sample_rate),
round((time_acc + (window_size or it.time)) * sample_rate)
)
slices.append((area, it.freq))
time_acc += it.time
return (round(time_acc * sample_rate), slices)
def match_frequencies(signal: Signal, slices: typing.List[typing.Tuple[slice, float]],
sample_rate: float, threshold: float = 50.0) -> bool:
# Check they're the correct frequencies
return all(abs(peak_fft_freq(signal[part], sample_rate) - freq) < threshold for part, freq in slices)
freq_to_color
— функция переводящая частоту тона в соответствующее значение цветового канала;
read_bits
— функция переводящая последовательность тонов в последовательность бит;
tones_to_slices
— утилитарная функция, отображение списка тонов в список слайсов сигнала;
match_frequencies
— функция-предикат, определяющая соответствие сигнала указанному списку тонов, если удалось найти все тона в сигнале, возвращает истинно (параметр threshold
— порог расхождения частот, по умолчанию 50 Гц).
Поиск заголовка SSTV-сигнала:
def find_header(
header: typing.Iterable[Tone],
signal: Signal,
threshold: float = 50.0,
stride_time: float = 0.002,
window_size: float = 0.010
) -> typing.Optional[typing.Tuple[int, int]]:
stride_len = round(stride_time * SAMPLE_RATE)
header_size, slices = tones_to_slices(header, SAMPLE_RATE, window_size)
for curr_sample in range(0, len(signal), stride_len):
if curr_sample + header_size >= len(signal):
continue
search_area = signal[curr_sample:curr_sample + header_size]
if match_frequencies(search_area, slices, SAMPLE_RATE, threshold=threshold):
return curr_sample, header_size
return None
Функция find_header
последовательно проходит по сигналу signal
и определяет вхождение последовательности тонов из параметра header
, в качестве результата выдает кортеж с данными о том, на каком семпле был найден заголовок и какая его продолжительность.
Декодирование VIS-кода:
def decode_vis(signal: Signal, vis_start: int, bit_time: float = VIS_WIDE_BIT_SIZE) -> int:
"""Decodes the vis from the audio data and returns the SSTV mode"""
bit_count = 8
vis_bits = list(
read_bits(
signal, bit_count, bit_time,
freq_true=BIT_1_WIDE_FREQ, freq_false=BIT_0_WIDE_FREQ,
sample_rate=SAMPLE_RATE, offset=vis_start
)
)
# Check for even parity in last bit
vis = vis_bits[:bit_count]
if sum(vis) % 2:
raise ValueError("Error decoding VIS header (invalid parity bit)")
vis_value = bits_to_int(vis[:-1])
if vis_value != VIS_CODE:
raise ValueError(f"SSTV mode is unsupported (VIS: {vis_value})")
return vis_value
Функция decode_vis
переводит последовательность тонов в нули и единицы, дополнительно проверяя бит четности. В рамках примера используется в демонстрационных целях, т.к. результат не используется, а только проверяется, что код соответствует формату Robot 72.
Синхронизация:
def align_sync(signal: Signal, align_start: int, start_of_sync: bool = True):
sync_window = round(SYNC_PULSE * 1.4 * SAMPLE_RATE)
align_stop = len(signal) - sync_window
if align_stop <= align_start:
return None
current_sample = align_start
for current_sample in range(align_start, align_stop):
search_section = signal[current_sample:current_sample + sync_window]
if peak_fft_freq(search_section, SAMPLE_RATE) > FREQ_SYNC_MEDIAN:
break
end_sync = current_sample + sync_window // 2
if start_of_sync:
return end_sync - round(SYNC_PULSE * SAMPLE_RATE)
else:
return end_sync
Функция align_sync
определяет точное нахождение импульса синхронизации в сигнале. Возвращаемое значение — номер сэмпла в исходном сигнале.
Алгоритм работы:
выбрать часть данных из сигнала;
найти в этом участке сигнала частоту, соответствующую частоте синхроимпульса.
Перевод данных строк в изображение:
def decode_image_data(signal: Signal, image_start: int) -> typing.List[typing.List[typing.List[int]]]:
image_data = [[[0 for _ in range(LINE_WIDTH)] for _ in range(CHANNELS)] for _ in range(LINE_COUNT)]
seq_start = image_start
for line in range(LINE_COUNT):
for chan in range(CHANNELS):
if chan == CHAN_SYNC:
if line > 0 or chan > 0:
# Set base offset to the next line
seq_start += round(LINE_TIME * SAMPLE_RATE)
# Align to start of sync pulse
seq_start = align_sync(signal, seq_start)
if seq_start is None:
return image_data
pixel_time = PIXEL_TIME
if chan > 0:
pixel_time = HALF_PIXEL_TIME
centre_window_time = (pixel_time * WINDOW_FACTOR) / 2
pixel_window = round(centre_window_time * 2 * SAMPLE_RATE)
for px in range(LINE_WIDTH):
chan_offset = CHAN_OFFSETS[chan]
px_pos = round(seq_start + (chan_offset + px * pixel_time - centre_window_time) * SAMPLE_RATE)
px_end = px_pos + pixel_window
if px_end >= len(signal):
return image_data
pixel_area = signal[px_pos:px_end]
freq = peak_fft_freq(pixel_area, SAMPLE_RATE)
image_data[line][chan][px] = freq_to_color(freq)
return image_data
def draw_image(image_data: typing.List[typing.List[typing.List[int]]]) -> Image:
image = Image.new(COLOR, (LINE_WIDTH, LINE_COUNT))
pixel_data = image.load()
for y in range(LINE_COUNT):
for x in range(LINE_WIDTH):
pixel_data[x, y] = (image_data[y][0][x], image_data[y][2][x], image_data[y][1][x])
image = image.convert("RGB")
return image
Функция decode_image_data
, в соответствии с форматом кодирования, формирует матрицу цветов. При прохождении канала, в котором необходимо выполнить синхронизацию, вызывается align_sync
для точного позиционирования в сигнале. Определение значения канала осуществляется через нахождение тона с максимальной амплитудой (peak_fft_freq
) и переводом частоты в значение цветового канала функцией freq_to_color
.
Функция draw_image
преобразует данные из decode_image_data
в финальное графическое изображение в формате RGB.
Значение параметра WINDOW_FACTOR
подбирается эмпирически.
Декодирование:
def decode(signal: Signal) -> Image:
if not (header := find_header(CALIBRATION_WIDE, signal)):
return None
hdr_start, hdr_len = header
hdr_end = hdr_start + hdr_len
print("Header start:", hdr_start)
print("Header end:", hdr_end)
bit_time = VIS_WIDE_BIT_SIZE
vis = decode_vis(signal, hdr_end)
print("VIS code:", vis)
if vis != VIS_CODE:
raise ValueError(f"Unsupported VIS code {vis}")
bit_len = 8 + 1 # 8 bits + 1 stub
vis_len = bit_time * bit_len * SAMPLE_RATE
vis_end = hdr_start + vis_len + hdr_len
image_data = decode_image_data(signal, round(vis_end))
return draw_image(image_data)
if __name__ == '__main__':
SAMPLE_RATE, signal = read("examples/robot72-example.wav")
img = decode(signal)
img.save("examples/robot72-example-out.png")
Фцнкция decode
собирает в себя всю вышеописанную логику воедино: поиск SSTV-заголовка, извлечение VIS-кода и декодирование сигнала в картинку.

Рисунок 10: изображение, полученное путем декодирования исходного сигнала.
На рисунке 10 приведен результат декодирования SSTV сигнала в формате Robot 72.
Примеры изображений
SSTV-сигналы, принятые R9FEU на диапазоне 20м.







Заключение
В статье был рассмотрен формат передачи изображений SSTV, принципы его работы, разобраны механизмы кодирования и декодирования сигнала на примере формата Robot 72.
DGG
например фотография обратной (темной) стороны Луны
Звучит как фейспалм.