摘要:
本文對業餘無線電中常用的 SSTV (慢速掃描電視) 圖像通訊模式進行解析,並以實際程式碼編寫的背景,分享了一些存在的問題。
一、簡要回顧
我最近正致力於一個專案,其中一個重要的功能是實現 SSTV 的調製。起初,我考慮著找一些現成的元件來隨意組裝,只要能夠運作就好。
結果令人意外的是,經過多次嘗試,在 GitHub 上找到的許多專案,要么根本無法啟動,要么效率極低。使用 PD-120 模式來調製一張 640x496 的圖片,需要耗費數十秒甚至更長時間,這絕對是讓人難以忍受的情況……
最後,我決定還是由我自己使用 C 語言重新實現 SSTV 的調製。在這個過程中,BI4PYM 提供了很大的幫助,我也學習並掌握了許多內容,同時也踩了不少的陷阱,正好在這裡與大家分享。
如圖所示,最終實現了約 6500% 的效能提升。(測試平台:Raspberry Pi Zero 2W)

GitHub 連結:SSTV-Modulation
二、SSTV 的組成
SSTV 包含標識頭和圖像數據。
1. VIS 標識頭
接觸過 SSTV 的朋友們應該都知道,SSTV 其實有許多種模式。那麼,該如何讓接收者知道您使用的是哪一種模式呢?當然,首先必須先向對方打招呼,告知對方您使用的模式後再開始傳輸。
所有標準的 SSTV 模式,在使用之前都會使用一個獨特的數字碼來告知接收系統該模式的使用。
這個程式碼稱為 VIS,也就是垂直間隔訊號代碼 (Vertical Interval Signal code)。
這段程式碼由七個二進位數字組成,並按照小端序排列。
您可以參考隨本文所附的手冊,以獲得某種模式的程式碼(見文章結尾)。

以 PD-120 模式為例:
手冊中標示該模式碼為「95 d」。將其轉換成二進位,即「1011111」。由於採用小端序排列,因此應按照「1111101」的順序傳輸。
那麼,該如何傳輸「1」和「0」呢?
我們透過不同頻率的音調來傳輸「1」和「0」。
「1」對應的頻率: 1100 赫茲
「0」對應的頻率:1300 赫茲
每個二進位的數字對應的音調持續 30 毫秒。
因此,與 PD-120 模式相應的音訊傳輸方式如下:

不過,在傳輸 VIS 碼之前,還需要先傳輸一些引導音。
標識頭不僅包含 VIS 碼,還包含一些額外的資訊。以下是整個標識頭的時間序列定義:

*驗證模式為偶數驗證,也就是包含檢查位的八位二進制碼中,「1」的數量應為偶數。
// 奇偶校验
int parity = 0;
for (int i = 0; i < data.length(); i++) {
parity ^= data[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 位二進位數表示的,也就是 0255 。那麼,如何才能透過音調來傳輸色彩強度呢?
SSTV 協定規定,色彩強度採用 1500Hz 至 2300Hz 的 800Hz 範圍來表示,色彩強度與頻率之間具有線性對應關係:
\text{頻率} = 1500 + \text{色彩強度} \times 3.1372549 \quad // 顏色頻率乘數,由 \frac{800}{255} 計算得出
在傳輸過程中,訊號的頻率會依照畫素點的顏色通道強度值,從 1500 Hz 到 2300 Hz 呈現線性變化。每次傳輸一行時,系統會將每個畫素點轉換為對應的頻率,並以固定的採樣率產生音訊信號。接收系統收到後,會依照色彩通道關係進行組裝,恢復圖像原本的顏色。
在每行數據的傳輸過程中,SSTV系統會發送一段特定的同步脈衝,以確保圖像的每一行都能正確對齊。每種模式的同步脈衝時序、頻率和長度都不相同,需要參考手冊中提供的時序表來進行設定。
為了方便說明,本段以「Scottie-DX」模式為例:

Scottie-DX 採用 RGB 色彩模式,圖像寬度為 320 像素,每列傳輸時間為 345.6 毫秒。
根據手冊可知,在 VIS 標識頭傳輸完畢後,首先傳輸一個 1200 Hz @ 9.0 ms 的起始掃描脈衝①。該脈衝僅在第一行傳輸前發送一次,為整個圖像傳輸提供同步起點的訊號。接著,傳輸一個 1500 Hz @ 1.5 ms 的分離脈衝②,用於區分不同的顏色通道。
在脈衝分離後,接著開始傳輸綠色通道的第一行數據 ③。每個像素根據其綠色通道的強度,產生對應的頻率:
f = 1500 + Green × COLOR\_FREQ\_MULT
每個像素的綠色分量值:綠色 (0255) 決定相應的頻率,每個像素的傳輸持續時間為 1.08 毫秒 (1.08 毫秒 = 345.6 毫秒 / 320 像素)。從該行的第一個像素開始,依序傳輸每個像素的綠色通道強度。
依照手冊的時間順序,在綠色掃描結束後,接著傳送一個 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() {
// 起始同步脈衝,僅第一行
tone(1200, 9, 0);
// 圖像資料部分
for(int line = 0; line < 10; line++) {
for(int col = 0; col < 8; col++) {
rgb(1, col, line);
}
}
} < 256; line++) {
// 分離脈衝
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, 相位略);
}
// 分離脈衝
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, 相位略);
}
// 同步脈衝與同步沿
tone(1200, 9, sign(oldercos) * asin(olderdata) + abs(sign(oldercos) - 1) / 2 * PI);
tone(1500, 1.5, sign(oldercos) * asin(olderdata) + abs(sign(oldercos) - 1) / 2 * PI);
// 紅色掃描
for(int x = 0; x < 320; for (x++) {
tone(1500 + rgb("r", x, line) * COLOR_FREQ_MULT, 1.08, 位差略);
}
}
}
除了 RGB 色彩模式之外,還有 Y、R-Y、B-Y 色彩模式。
轉換關係如下:
Y = 16 + 0.003906 × (65.738 × R + 129.057 × G + 25.064 × B)
R-Y = 128 + 0.003906 × (112.439 × R - 94.154 × G - 18.285 × B)
B-Y = 128 + 0.003906 × (-37.945 × R - 74.494 × G + 112.439 × B)
不同的 SSTV 模式具有不同的色彩模式和調製時序,因此必須依照手冊的操作方式 (手冊請見文末)。
值得注意的是,並非所有 SSTV 模式都採用逐行掃描的方式,有些模式可能採用逐兩行掃描,例如 PD 系列。

PD 系列的 SSTV 採用 Y、R-Y、B-Y 的色彩模式。
根據手冊說明,PD 系列的 SSTV 模式首先傳輸奇數行的 Y 通道強度(從第 0 行開始),然後傳輸該奇數行及其下方的偶數行的 R-Y、B-Y 強度的平均值,最後傳輸偶數行的 Y 通道強度。 這樣可以一次掃描兩行。
簡化的傳輸過程如下:

由於模式實在太多種,無法一一說明,因此只能以上述兩種模式為例。
其他模式的具體調製還需要依照手冊進行(手冊見於文末)。
三、遇到的一些問題
起初,我原本打算直接將由調製產生的音訊從聲卡輸出,但經過一番嘗試,實在難以實現,最終只能先使用一個「wav」容器來儲存調製後的音訊。 調製完一段音訊後,發現檔案尺寸居然高達25兆……然後我就開始想辦法縮小檔案大小。
如同眾人皆知,一個 WAV 檔案的大小與以下幾個參數有關:
- 採樣率
- 深度
- 頻道數
其中,通道數和位深度已經盡可能調整到最低要求,接下來就要針對採樣率下手。 在之前的解析中,我們已經知道所有 SSTV 模式的最大頻率不超過 2500Hz,根據奈奎斯特定理:
$f_{\text{sample}} = 2 \times f_{\text{max}}$
理論上,只需要 5000Hz 的採樣率即可正常儲存調製後的訊號。為了確保備用性,最終我使用了 6000Hz 的採樣率。檔案大小減少超過 80%。
縮減百分比 = (1 - 6000/44100) × 100 ≈ 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);
}
tone(freq, time, sign(oldercos) * asin(olderdata) + abs(sign(oldercos) - 1) / 2 * PI);
然而,接下來還有一個問題:因為像素長與採樣率的乘積並非整數,直接四捨五入會導致每個像素的時間長略有縮短。

最後,BI4PYM 採用累積誤差補償,將小數位相加,當誤差超過一個採樣時長時,則補回一個採樣時長,從而消除時長誤差。這部分程式碼的實現也位於之前的 C 語言程式碼區塊中。
基本上,這些就是我遇到的問題。非常感謝 BI4PYM 在協助和理論上的支持。
目前這個程式雖然基於 C 語言的優勢,獲得了顯著的效能提升,但整體效能仍需進一步優化,歡迎大家提出建議。
附件:
這個論壇似乎不支援 PDF 檔案… 請參考我在「科創」發佈的貼文:
https://www.kechuang.org/t/90795