本人最近正在着手推进某个项目,其中要实现的一个重要功能就是 SSTV 的调制。我最开始想,找点轮子胡乱组装一下,能跑起来就行了嘛。
结果没想到,试了好几个 GitHub 上的项目,要么就是莫名其妙跑不起来,要么就是效率极其低下,使用 PD-120 模式调制一张640*496的图片,耗时动辄50秒以上,这绝对是难以忍受的……
最后我决定还是自己用 C语言重新实现 SSTV 的调制。在这个过程中,BI4PYM提供了一些帮助,我也学习了解了不少的内容,也踩到了不少的坑,刚好就在这里和大家分享一下。
如图:最终实现了大约6500%的性能提升。(测试平台:Raspberry Pi Zero 2W)
Github地址:SSTV-Modulation
SSTV 由标识头和图像数据组成。
接触过 SSTV 的朋友应该都知道, SSTV 有非常多种模式。那么,该怎么让接收者知道你使用的是哪种调制模式呢?自然需要先朝对方打个招呼,告知对方使用何种模式后再开始传输。
所有标准 SSTV 模式在开始前都使用一个独特的数字代码来向接收系统标识该模式。
该代码称为 VIS,即垂直间隔信号代码(Vertical Interval Signal code)。
该代码由七位二进制数组成,按小端序排列。
我们可以参考随本文上传的一份手册来获得某种模式的代码。(欸,文件该怎么传来着)
以 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。
标识头传输完成后,便立即进入图像数据的传输阶段。
SSTV 的图像数据采用逐行扫描的方式,每一行对应几段音频信号。
不同模式的 SSTV 选用的色彩模式不同,具体需要参考手册所给出的色彩模式。不过,具体原理大差不差:
我们知道每种色彩强度是由 8 位二进制数表示的,即 0~255 。那么,如何在用音调实现色彩强度的传输呢?
SSTV 协议规定了,色彩强度使用从 1500Hz 到 2300Hz 的 800Hz 范围表示,色彩强度和频率是线性对应的:
$$\text{FREQ} = 1500 + \text{色彩强度} \times 3.1372549 \quad \text{// 颜色频率乘数,由 } \frac{800}{255} \text{ 得出} $$
传输过程中,信号的频率按照像素点的颜色通道强度值从 1500 Hz 到 2300 Hz 线性变化。传输每一行时,系统会将像素点逐一转换为相应的频率,并以固定的采样率生成音频信号。接收系统接收完毕后,便会按色彩通道关系将其组装起来,复原图像原有的色彩。在每行数据的传输中,SSTV 系统都会发送一段特定的的同步脉冲,从而确保图像的行与行之间能够准确对齐。每种模式的同步脉冲时序、频率、长度都不尽相同,需要阅读手册中提供的时序表来进行实现。
为了便于说明,本段使用 Scottie-DX 模式举例:
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系列。
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文件的大小与一下几个参数有关:
采样率
位深度
通道数
其中通道数和位深度都已经尽量调整到了最低要求,接下来就是向采样率开刀。在前面的解析中,我们已经知道所有 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\%$$
存储占用问题先告一段落。接下来是生成的音频的问题。最开始我想的比较简单,在生成信号时只考虑了频率和时长问题,忽略了相位问题。但是,实际调制出来的音频实极其刺耳,在频谱里有非常多的杂乱的频率分量。相位不连续的突变导致了杂乱频率分量的产生,要调制的信号产生了极大的失真。
后来引入了一些变量,实现了相位的连续,解调出来的图像也恢复了正常。
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);
但是接下来还有一个问题:因为像素时长与采样率的乘积不是一个整数,直接取整会导致每像素的时长都有些许缩短。
最后BI4PYM 使用累积误差补偿,将小数位累加起来,当误差超过一个采样时长时补上一个采样时长,消除了时长误差。这一部分的代码实现也位于上个 C语言 代码块中。
以上大致就是碰到的一些问题了。很感谢BI4PYM提供的一系列帮助和理论支持~
目前的程序虽然基于C语言的性能优势,获得了巨大的性能提升,但是整体性能仍然有待优化,欢迎大家提出改进意见。
[修改于 19时23分前 - 2024/12/21 15:43:07]