要旨:
本稿では、アマチュア無線で一般的に使用されるSSTV(スロー・スキャン・テレビ)の画像通信方式について解説し、実際にコードを記述した上で、その中で発生する可能性のある問題点を共有しています。
1. 前回の概要
私は最近、あるプロジェクトに取り組み始めており、その中で重要な機能の一つであるSSTVの変調を実現する必要がある。当初は、既存のものを参考にざっくりと組んで動くようにするという方法を考えていた。
結果、予想に反して、いくつかのGitHub上のプロジェクトを試してみたところ、いずれも起動しないか、非常に低い効率しか得られませんでした。PD-120モードで640×496の画像を送信するのに、50秒以上かかることがありました。これは耐え難い状況です…。
最後に、私はC言語でSSTVの再実装を決意しました。この過程で、BI4PYMからのサポートは非常に大きかったです。また、多くの知識を得ることができましたが、同時に多くの課題にも直面しました。そこで、皆さんとこの経験を共有したいと思います。
例えば:最終的に約6500%の性能向上を実現しました。(テスト環境:Raspberry Pi Zero 2W)

GitHub のアドレス:SSTV-Modulation
2. SSTV の構成
SSTV は、識別ヘッダーと画像データで構成されます。
1. VIS 識別ヘッダー
SSTV に詳しい方ならご存知の通り、SSTV には様々なモードがあります。そこで、受信者があなたがどのモードを使用しているのかを知るには、まず相手に挨拶をして、使用するモードを伝えてから送信を開始する必要があります。
すべての標準的なSSTVモードは、開始前に独自の数字コードを使用して受信システムにそのモードを識別させる。
このコードは「VIS」、すなわち垂直間隔信号コードと呼ばれています。
このコードは、7つの2進数の組み合わせで構成されており、小さい方から順に並べられています。
本記事に添付されているマニュアルを参照することで、特定のパターンに対応するコードを入手できます。(記事の末尾に記載)

PD-120 のモードを例として挙げます。
マニュアルには、このモードコードが「95 d」と記載されています。「95 d」をバイナリに変換すると、「1011111」となります。また、小端序で配置されているため、「1111101」の順に送信する必要があります。
では、どのように「1」と「0」を伝送すれば良いのでしょうか?
「1」と「0」を、異なる周波数の音調で伝送します。
「1」に対応する周波数:1100 Hz
「0」に対応する周波数:1300 Hz
各々のバイナリ数値に対応する音符は、30ms間持続します。
したがって、PD-120モードに対応するオーディオ送信は以下のとおりです。

ただし、VISコードの送信前に、いくつかのガイダンス音声も送信する必要があります。
識別頭には、VISコードだけでなく、追加の情報も含まれています。以下に、識別頭全体のタイミング定義を示します。

* チェック方式は、偶数チェックであり、チェックビットを含む8ビットの二進数が「1」を含み、その数は偶数である必要があります。
// 偶数チェック
int parity = 0;
for (int i = 0; i < data.length(); i++) {
parity += data[i];
}
if (parity % 2 == 0) {
// 偶数の場合
} else {
// 奇数の場合
} < 7; i++) {
if (vis_code[i] == '1') {
parity++;
}
}
parity_bit = (parity % 2 == 0) ? 0 : 1;
tone((parity_bit == 0) ? 1300 : 1100, 30, 0);
VIS は単に7ビットのパターン識別コードを指しますが、ガイド音やチェックビットなど、それらを含むすべての識別ヘッダーは、慣例的に「VIS」と呼ばれています。
2. 画像データ
識別ヘッダーの送信が完了した後、すぐに画像データの送信段階に入ります。
SSTV の画像データは、1 行ずつスキャンすることで生成され、各行には複数のオーディオ信号に対応します。
異なるモードでSSTVを使用する場合、使用する色パターンは異なるため、詳細はマニュアルに記載されている色パターンを参照してください。ただし、基本的な原理はほとんど同じです。各色の強度は、8ビットのバイナリ数値(つまり0)で表現されることを知っています。255 では、音の高さを使って色の強さを伝える方法について、具体的にどのようなものがあるのでしょうか?
SSTVプロトコルでは、色強度は1500Hzから2300Hzの範囲で、800Hzの間隔を使用して表現され、色強度と周波数は線形に対応していることが規定されています。
\text{周波数} = 1500 + \text{色強度} × 3.1372549 // 色の周波数係数。$\frac{800}{255}$から算出
送信中に、信号の周波数はピクセルごとの色チャンネルの強度値に応じて、1500 Hz から 2300 Hz の範囲で線形に変化します。各行を送信する際、システムはピクセルごとに対応する周波数に変換し、一定のサンプリングレートでオーディオ信号を生成します。受信システムが信号を受信した後、色チャンネルの関係に基づいて再構成され、元の画像の色彩が復元されます。
データが1行ずつ送信される際、SSTVシステムは特定の同期パルスを送信し、これにより画像の行と行の正確な整列を保証します。各モードの同期パルスのタイミング、周波数、長さはすべて異なっており、これらの情報はマニュアルに記載されている表を参照して設定する必要があります。
説明を容易にするため、ここでは「Scottie-DX」モードの例を用いて説明します。

スコッティ-DXは、R、G、Bのカラーモードを採用し、画像の幅は320ピクセルで、1行あたりのデータ転送時間は345.6ミリ秒です。
マニュアルによると、VIS 識別ヘッダーの送信が完了した後、最初に 1200 Hz @ 9.0 ms の開始スキャンパルス① を送信します。このパルスは、最初の行を送信する前に一度だけ送信され、画像全体の送信における同期点の信号として機能します。その後、1500 Hz @ 1.5 ms の分離パルス② を送信し、異なる色チャンネルを区別するために使用します。
分離パルス終了後、すぐに緑色のチャネルの最初の行のデータを送信を開始します ③。各ピクセルは、その緑色のチャネルの強度に応じて、対応する周波数の音程を生成します。
f = 1500 + GREEN × COLOR\_FREQ\_MULT
各ピクセルの緑色の成分値:緑(0255)対応する周波数を決定し、各ピクセルのデータ転送時間を以下のように設定します。 1.08 ミリ秒1.08ms = 345.6ms / 320px)。この行の最初のピクセルから開始し、各ピクセルの緑色のチャネル強度を順番に送信します。
マニュアルに記載されているタイミングに従い、緑色のスキャンが完了した後、まず1500 Hz @ 1.5 ms の分離パルス④を送信し、その後、緑色のスキャンと同様に、次の色(青)のスキャン⑤を開始します。青色のスキャンが完了した後、1200 Hz @ 9.0 ms の同期パルス⑥と1500 Hz @ 1.5 ms の同期沿⑦を送信し、その後、赤色のスキャン⑧を続行します。赤色のスキャンが完了した後、この行の送信が終了し、次の行(ステップ②~⑧)の送信を開始します。受信側では、受信した内容に基づいて、RGBの3つのチャンネルを組み合わせて、最初の彩色画像の1行を復元することができます。
画像のすべての行をスキャンした後、プログラムは自動的に終了します。また、終了時に特別な音を追加することも可能です。
以下は、簡略化された変調関数の一例です。
//トーン(周波数、期間、位相):信号を送信する関数
//rgb(色チャネル、ピクセル横座標、ピクセル縦座標):特定の場所のピクセルの色強度を読み取る関数
//COLOR_FREQ_MULT:色周波数の乗数
// 関数:スコッティ-DX の生成
void generate_scottie_dx() {
// 開始同期パルス、最初の行のみ
tone(1200, 9, 0);
// 画像データ部分
for(int line = 0; 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 = 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 × (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モードが1行ずつスキャンするわけではないことです。一部のモードでは、2行ずつスキャンすることがあります。例えば、PDシリーズなどが該当します。

PDシリーズのSSTVは、Y、R-Y、B-Yの色モードを使用します。
マニュアルによると、PDシリーズのSSTVモードでは、まず偶数の行のYチャンネル強度(PDシリーズSSTVは0行から開始)を送信し、その後、その偶数行とその下にある奇数行のR-Y、B-Yチャンネルの平均値を送信し、最後に奇数行のYチャンネル強度を送信します。これにより、一度のスキャンで2行のデータを取得できます。
簡略化された伝送プロセスは以下の通りです。

パターンは非常に多様であるため、すべてを説明することはできません。そのため、上記のような2つのパターンを例として挙げさせていただきます。
その他のモードの具体的な変調については、マニュアルを参照してください。(マニュアルは本文末に記載されています。)
3. 発生した問題点
最初は、生成されたオーディオ信号をサウンドカードから直接出力することを試しましたが、半日もかけても実現できませんでした。結局、wavコンテナを使ってモデレートしたオーディオを保存することになりました。モデレートしたオーディオのファイルサイズがなんと25兆バイトにもなったのです。その後、ファイルサイズを削減する方法を探し始めました。
広く知られているように、WAVファイルのサイズは以下のパラメータによって決まります。
- サンプリングレート
- 深さ
- チャネル数
その中で、チャンネル数とビット深度は可能な限り最小の要件に調整されています。次に、サンプリングレートを重点的に調整します。前回の解析で、すべてのSSTVモードにおける最大周波数が2500Hzを超えないことがわかっています。これに基づき、ナイキストの定理に従って:
$f_{\text{サンプル}} = 2 \times f_{\text{最大}}$
理論的には、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は累積誤差を補正し、小数点以下の値を合計します。誤差が1サンプリング時間を超えた場合に、1サンプリング時間を補填することで、時間的な誤差を取り除きます。このコードの実装も、前のC言語のコードブロックにあります。
上記は、私が直面した主な問題の概要です。BI4PYMさんからの様々なサポートと理論的な助けに感謝しています。
現在のプログラムは、C言語の性能を活かして大幅な改善を実現していますが、全体的な性能についてはさらなる最適化が必要です。皆様からのご意見や提案をお待ちしております。
添付資料:
このフォーラムでは、PDF形式のファイルの共有がサポートされていないようです。代わりに、私が「科創」で投稿したものを参照してください。
https://www.kechuang.org/t/90795