SSTV(慢扫描电视)的调制
BG7ZDQBI4PYM
1.中北大学无线电协会
中文摘要
本文对业余无线电常用的SSTV(慢扫描电视)图像通讯模式进行了解析,并以实际代码编写的背景分享了若干存在的问题。
关键词
业余无线电SSTV
Ham RadioSSTV

一、前情提要


本人最近正在着手推进某个项目,其中要实现的一个重要功能就是 SSTV 的调制。我最开始想,找点轮子胡乱组装一下,能跑起来就行了嘛。

结果没想到,试了好几个 GitHub 上的项目,要么就是莫名其妙跑不起来,要么就是效率极其低下,使用 PD-120 模式调制一张640*496的图片,耗时动辄50秒以上,这绝对是难以忍受的……

最后我决定还是自己用 C语言重新实现 SSTV 的调制。在这个过程中,BI4PYM提供了一些帮助,我也学习了解了不少的内容,也踩到了不少的坑,刚好就在这里和大家分享一下。

如图:最终实现了大约6500%的性能提升。(测试平台:Raspberry Pi Zero 2W)

image.png

Github地址:SSTV-Modulation

二、SSTV 的组成


SSTV 由标识头和图像数据组成。

1. VIS 标识头

接触过 SSTV 的朋友应该都知道, SSTV 有非常多种模式。那么,该怎么让接收者知道你使用的是哪种调制模式呢?自然需要先朝对方打个招呼,告知对方使用何种模式后再开始传输。


所有标准 SSTV 模式在开始前都使用一个独特的数字代码来向接收系统标识该模式。

该代码称为 VIS,即垂直间隔信号代码(Vertical Interval Signal code)。

该代码由七位二进制数组成,按小端序排列。


我们可以参考随本文上传的一份手册来获得某种模式的代码。(欸,文件该怎么传来着)

image.png

以 PD-120 模式为例:

手册中标明该模式代码为“95 d”。将其转换为二进制,即“1011111”。又因为采取小端序排列,所以应按“1111101”的顺序传输。


那么,该怎么传输“1”和“0”呢?

我们通过不同频率的音调来传输"1" 和 "0"。


“1”对应频率: 1100 Hz

“0”对应频率: 1300 Hz

每一位二进制数对应的音调持续30 ms。


所以PD-120模式对应的音频传输如下:

值(0或1)

频率(Hz)

持续时间(ms)

1

1

1100

30

2

1

1100

30

3

1

1100

30

4

1

1100

30

5

1

1100

30

6

0

1300

30

7

1

1100

30


不过,在传输 VIS 码前还要先传输一些引导音。

标识头不光包含了 VIS 代码,还包含了一些额外的信息。如下是整个标识头的时序定义:


时长 (ms)

频率 (Hz)

类型

300

1900

引导音

10

1200

中断

300

1900

引导音

30

1200

VIS 起始位

30

bit0

1100 Hz = 1, 1300 Hz = 0

30

bit1

1100 Hz = 1, 1300 Hz = 0

30

bit2

1100 Hz = 1, 1300 Hz = 0

30

bit3

1100 Hz = 1, 1300 Hz = 0

30

bit4

1100 Hz = 1, 1300 Hz = 0

30

bit5

1100 Hz = 1, 1300 Hz = 0

30

bit6

1100 Hz = 1, 1300 Hz = 0

30

偶校验*

偶数 = 1300 Hz, 奇数 = 1100 Hz

30

1200

VIS 结束位


*校验模式为偶校验,即包括校验位在内的八位二进制码中的“1”应为偶数个。

    // 偶校验
    int parity = 0;
    for (int i = 0; i < 7; i++) {
        if (vis_code[i] == '1') {
            parity++;
        }
    }
    int parity_bit = (parity % 2 == 0) ? 0 : 1;
    tone((parity_bit == 0) ? 1300 : 1100, 30, 0);


虽然 VIS 指的仅的是七位模式标识码,但是包括引导音、校验位等在内的整个标识头都被习惯性的称为 VIS。

2. 图像数据

标识头传输完成后,便立即进入图像数据的传输阶段。

SSTV 的图像数据采用逐行扫描的方式,每一行对应几段音频信号。


不同模式的 SSTV 选用的色彩模式不同,具体需要参考手册所给出的色彩模式。不过,具体原理大差不差:

我们知道每种色彩强度是由 8 位二进制数表示的,即 0~255 。那么,如何在用音调实现色彩强度的传输呢?


SSTV 协议规定了,色彩强度使用从 1500Hz2300Hz 800Hz 范围表示,色彩强度和频率是线性对应的:

$$\text{FREQ} = 1500 + \text{色彩强度} \times 3.1372549 \quad \text{// 颜色频率乘数,由 } \frac{800}{255} \text{ 得出} $$

传输过程中,信号的频率按照像素点的颜色通道强度值从 1500 Hz2300 Hz 线性变化。传输每一行时,系统会将像素点逐一转换为相应的频率,并以固定的采样率生成音频信号。接收系统接收完毕后,便会按色彩通道关系将其组装起来,复原图像原有的色彩。


在每行数据的传输中,SSTV 系统都会发送一段特定的的同步脉冲,从而确保图像的行与行之间能够准确对齐。每种模式的同步脉冲时序、频率、长度都不尽相同,需要阅读手册中提供的时序表来进行实现。


为了便于说明,本段使用 Scottie-DX 模式举例:

image.png

Scottie-DX 采用 R, G, B 色彩模式,图像宽 320px,每行传输时间 345.6ms


根据手册可知,在 VIS 标识头传输完毕后,首先传输一个 1200 Hz @ 9.0 ms 的起始扫描脉冲。该脉冲仅在第一行传输前发送一次,为整个图像传输提供同步起点的信号。接着,传输一个 1500 Hz @ 1.5 ms 的分离脉冲,用于区分不同的颜色通道。


分离脉冲结束后,紧接着开始传输第一行的绿色通道的数据。每个像素根据其的绿色通道的强度,生成对应频率的音调:

$$f = 1500 + Green \times \text{COLOR_FREQ_MULT}$$

每个像素的绿色分量值 Green (0~255)决定对应的频率,每个像素的传输持续时间为 1.08 ms($1.08ms=345.6ms/320px$)。从该行的第一个像素开始,依次传输每个像素的绿色通道强度。


依据手册的时序,绿色扫描结束后,紧接着传输一个 1500 Hz @ 1.5 ms 的分离脉冲,然后像绿色扫描一样开始下一个颜色(蓝色)的扫描。蓝色扫描结束后,紧接着传输一个 1200 Hz @ 9.0 ms 的同步脉冲和一个 1500 Hz @ 1.5 ms 的同步沿,然后继续进行红色扫描。红色扫描结束后,该行传输完毕,开始下一行的传输 (步骤②到⑧)。接收端此时可以依据所接收到的内容将RGB三个通道组装到,一起复原出彩色图像的第一行。


当图像的所有行扫描完毕后,程序自动结束。你也可以额外增加一个结束音。


如下是一段简化后的调制函数:

//tone(频率, 时长, 相位):传输信号的函数
//rgb(颜色通道, 像素横坐标, 像素纵坐标):读取某处像素颜色强度的函数
//COLOR_FREQ_MULT:颜色频率乘数

// 函数:调制 Scottie-DX
void generate_scottie_dx() {
    
    // 起始同步脉冲,仅第一行
    write_tone(1200, 9, 0);

    // 图像数据部分
    for(int line = 0; line < 256; line++) {
        // 分离脉冲
        write_tone(1500, 1.5, sign(oldercos) * asin(olderdata) + abs(sign(oldercos) - 1) / 2 * PI);

        // 绿色扫描
        for(int x = 0; x < 320; x++) {
            tone(1500 + rgb("g",x,line)*COLOR_FREQ_MULT, 1.08, 相位略);
        }

        // 分离脉冲
        write_tone(1500, 1.5, sign(oldercos) * asin(olderdata) + abs(sign(oldercos) - 1) / 2 * PI);

        // 蓝色扫描
        for(int x = 0; x < 320; x++) {
            tone(1500 + rgb("b",x,line)*COLOR_FREQ_MULT, 1.08, 相位略);
        }

        // 同步脉冲与同步沿
        write_tone(1200, 9, sign(oldercos) * asin(olderdata) + abs(sign(oldercos) - 1) / 2 * PI);
        write_tone(1500, 1.5, sign(oldercos) * asin(olderdata) + abs(sign(oldercos) - 1) / 2 * PI);

        // 红色扫描
        for(int x = 0; x < 320; x++) {
            tone(1500 + rgb("r",x,line)*COLOR_FREQ_MULT, 1.08, 相位略);
        }
    }
}


除了 R, G, B 色彩模式外,还有一种 Y, R-Y, B-Y 色彩模式。

转化关系如下:

$$Y = 16 + 0.003906 \times (65.738 \times R + 129.057 \times G + 25.064 \times B) $$

$$R-Y = 128 + 0.003906 \times (112.439 \times R - 94.154 \times G - 18.285 \times B)$$

$$B-Y = 128 + 0.003906 \times (-37.945 \times R - 74.494 \times G + 112.439 \times B)$$

不同SSTV模式的色彩模式和调制时序都不尽相同,需要按照手册操作。


值得一提的是,并不是所有的SSTV模式都是逐单行扫描,部分模式可能是逐两行扫描的,例如PD系列。

image.png

PD 系列 SSTV 采用 Y, R-Y, B-Y 色彩模式。


依据手册,PD 系列的 SSTV 模式首先传输偶数行的Y通道强度(PD 系列 SSTV 从第 0 行记起),然后传输该偶数行和其下奇数行的 R-Y, B-Y 强度中值,最后传输奇数行的Y通道强度。实现了一次扫描两行。


简化的传输过程如下:

步骤

传输内容

1

传输第 0 行 Y 通道强度

2

传输第 0 行和第 1 行 R-Y 通道中值

3

传输第 0 行和第 1 行 B-Y 通道中值

4

传输第 1 行 Y 通道强度

5

传输第 2 行 Y 通道强度

6

传输第 2 行和第 3 行 R-Y 通道中值

7

传输第 2 行和第 3 行 B-Y 通道中值

8

传输第 3 行 Y 通道强度


由于模式实在是多种多样,没有办法全部讲解,便也只能拿如上两种模式举例了。其他模式具体的调制还需要依照手册进行。

三、碰到的一些问题

最开始,我想直接将调制生成的音频从声卡输出,但是试验了半天实在难以实现,不得已只能先用个 wav 容器将调制出来的音频装起来。调制完一段音频发现,文件尺寸居然高达25兆……然后就在想办法削减文件大小。


众所周知,一个WAV文件的大小与一下几个参数有关:

  1. 采样率

  2. 位深度

  3. 通道数

其中通道数和位深度都已经尽量调整到了最低要求,接下来就是向采样率开刀。在前面的解析中,我们已经知道所有 SSTV 模式的最大频率并不超过 2500Hz ,依据奈奎斯特定理:


$$f_{\text{sample}} = 2 \times f_{\text{max}}$$

理论上只需要 5000Hz 的采样率即可正常存纳调制出的信号。冗余起见,我最终使用了 6000Hz 的采样率。文件尺寸缩减了80%以上。

$$\text{缩减百分比} = \left( 1 - \frac{6000}{44100} \right) \times 100 \approx 86.4\%$$

存储占用问题先告一段落。


接下来是生成的音频的问题。最开始我想的比较简单,在生成信号时只考虑了频率和时长问题,忽略了相位问题。但是,实际调制出来的音频实极其刺耳,在频谱里有非常多的杂乱的频率分量。相位不连续的突变导致了杂乱频率分量的产生,要调制的信号产生了极大的失真。


8729207deb858544281d37a7db464f46.png


image.png

后来引入了一些变量,实现了相位的连续,解调出来的图像也恢复了正常。

double olderdata;             // 前一个幅度,用于连续相位
double oldercos;              // 前一个COS,用于连续相位

// 函数:生成并写入指定频率和持续时间和初始相位的正弦波音频
void write_tone(double frequency, double duration_ms, double phi) {
    uint32_t num_samples = SAMPLE_RATE * duration_ms / 1000;
    delta_lenth += SAMPLE_RATE * duration_ms / 1000 - num_samples;
    if (delta_lenth >= 1) {
        num_samples += (int)delta_lenth;
        delta_lenth -= (int)delta_lenth;
    }
    double phi_samples = SAMPLE_RATE * phi;
    short buffer[num_samples];
    for (uint32_t i = 0; i < num_samples; ++i) {
        buffer[i] = (short)(32767 * sin((2 * PI * frequency * i + phi_samples) / SAMPLE_RATE));
    }
    fwrite(buffer, sizeof(short), num_samples, file);
    total_samples += num_samples;
	olderdata = sin((2 * PI * frequency * num_samples + phi_samples) / SAMPLE_RATE);
    oldercos = cos((2 * PI * frequency * num_samples + phi_samples) / SAMPLE_RATE);
}

write_tone(freq, time, sign(oldercos) * asin(olderdata) + abs(sign(oldercos) - 1) / 2 * PI);


但是接下来还有一个问题:因为像素时长与采样率的乘积不是一个整数,直接取整会导致每像素的时长都有些许缩短。

dc74c4fe2ff9874dca6c16c9a1604fe7.png

最后BI4PYM 使用累积误差补偿,将小数位累加起来,当误差超过一个采样时长时补上一个采样时长,消除了时长误差。这一部分的代码实现也位于上个 C语言 代码块中。


以上大致就是碰到的一些问题了。很感谢BI4PYM提供的一系列帮助和理论支持~


目前的程序虽然基于C语言的性能优势,获得了巨大的性能提升,但是整体性能仍然有待优化,欢迎大家提出改进意见。


[修改于 8时26分前 - 2024/12/21 15:43:07]

+0.5  科创币    罗布Carrot    2024/12/21 你做的好啊,你做的好啊(赞赏)
来自:电子信息 / 无线电计算机科学 / 软件综合
0
6
已屏蔽 原因:{{ notice.reason }}已屏蔽
{{notice.noticeContent}}
~~空空如也

想参与大家的讨论?现在就 登录 或者 注册

山雨欲来风满楼
进士 机友 笔友
文章
4
回复
32
学术分
0
2022/12/24注册,29分19秒前活动

业余无线电爱好者,呼号BG7ZDQ。 中北大学飞行器设计与工程专业在读。

主体类型:个人
所属领域:无
认证方式:手机号
IP归属地:海南
插入公式
评论控制
加载中...
文号:{{pid}}
投诉或举报
加载中...
{{tip}}
请选择违规类型:
{{reason.type}}

空空如也

加载中...
详情
详情
推送到专栏从专栏移除
设为匿名取消匿名
查看作者
回复
只看作者
加入收藏取消收藏
收藏
取消收藏
折叠回复
置顶取消置顶
评学术分
鼓励
设为精选取消精选
管理提醒
编辑
通过审核
评论控制
退修或删除
历史版本
违规记录
投诉或举报
加入黑名单移除黑名单
查看IP
{{format('YYYY/MM/DD HH:mm:ss', toc)}}