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 состоит из:

  1. заголовка, по которому приемник определяет начало и формат передаваемого сигнала, относительно которого будет происходить декодирование строк;

  2. импульса синхронизации строки;

  3. последовательно закодированных строк изображения.

Заголовок сигнала

Заголовок сигнала в свою очередь состоит из серии калибровочных импульсов и 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 определяет точное нахождение импульса синхронизации в сигнале. Возвращаемое значение — номер сэмпла в исходном сигнале.

Алгоритм работы:

  1. выбрать часть данных из сигнала;

  2. найти в этом участке сигнала частоту, соответствующую частоте синхроимпульса.

Перевод данных строк в изображение:

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.

Ссылки

  1. Исходный код кодировщика Robot 72

  2. Исходный код декодера Robot 72

  3. Исходный код родительской программы, с которой были сформированы примеры

  4. Исходный код программы, на базе которой были реализованы остальные форматы

  5. Основной опорный документ по SSTV

  6. Исходный код программы MMSSTV

  7. Галерея изображений

Комментарии (1)


  1. DGG
    31.05.2025 06:26

    например фотография обратной (темной) стороны Луны

    Звучит как фейспалм.