Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

sstv-encoder

🇬🇧 English | 🇹🇷 Türkçe

A pure-Python SSTV encoder that turns any PNG image into a transmittable audio signal — and explains, layer by layer, exactly how a picture becomes sound.

This is the mirror image of our sister project sstv-decoder: the decoder reads pictures out of audio, this encoder writes them in. Together they form a complete, self-verifying SSTV chain built from nothing but numpy, scipy and Pillow.

PNG ──► scale to the mode's fixed size (e.g. 640×496 for PD120)
    ──► pixel → tone   (1500 Hz = black … 2300 Hz = white)
    ──► signal plan    (VOX tones + leader + VIS + sync'd scan lines)
    ──► continuous-phase FM synthesis (phase = Σ 2π·f/fs) ──► WAV

Contents

  1. What does an SSTV encoder do?
  2. Quick start
  3. Input rules and supported modes
  4. From pixels to tones — the core idea
  5. Anatomy of the generated signal
  6. Colour: GBR and YCrCb
  7. The synthesis engine — low level
  8. Code walkthrough
  9. Outputs and the frequency analysis
  10. Closed-loop verification
  11. Transmitting on the air
  12. Glossary
  13. Files and requirements
  14. References

1. What does an SSTV encoder do?

SSTV (Slow-Scan Television) sends still pictures over an ordinary voice radio channel by mapping brightness to audio pitch: dark pixels become low tones, bright pixels high tones. The receiver listens and paints what it hears. (The sstv-decoder README tells the full story of the format; this document tells the story of producing it.)

An encoder is the transmitting half of that agreement. It takes an image and answers one question for every instant of the next two minutes:

"What frequency must the speaker emit right now, so that any SSTV receiver on Earth — hardware from 1975 or our own Python decoder — reconstructs my picture?"

The answer is a single continuous curve of frequency over time. Everything this program does — scaling, colour conversion, headers, sync pulses, phase-continuous synthesis — exists to draw that one curve correctly and then turn it into a WAV file you can play into a transmitter.

2. Quick start

pip3 install numpy scipy pillow matplotlib

# Martin M1 (default): 320×256, ~116 s of audio
python3 sstv_encoder.py misc/ta2ldv-sample.png

# PD120 (VIS code 95): 640×496, ~128 s of audio
python3 sstv_encoder.py misc/ta2ldv-sample.png 95

The second argument is the VIS code of the desired mode (see the table below). Every run writes three files under data/ and prints a full report to the console.

3. Input rules and supported modes

Rule Value Why
Format PNG only lossless, single unambiguous variant; the decoder's output format too
Width × height any — automatically scaled to the mode's fixed size SSTV mode dimensions are protocol constants, not choices
Colour 24-bit RGB (alpha is dropped, grayscale is converted) the transmitted signal carries continuous-tone colour
Aspect ratio match the mode's (5:4 for 320×256, ~1.29 for 640×496) or accept distortion scaling does not letterbox

The transmitted image size is always the mode's size. Feed it a 6000×4800 photograph and a 320×256 mode still transmits 320×256 — the protocol fixes it, both sides know it from the VIS code, and no input can change it. Detail beyond the mode's resolution is simply lost, so make text large and bold.

Supported modes (VIS code → mode):

VIS Mode Image size Line time Image time Colour
44 Martin M1 (default) 320×256 446.446 ms 114.3 s GBR
40 Martin M2 320×256 226.798 ms 58.1 s GBR
60 Scottie S1 320×256 428.220 ms 109.6 s GBR
56 Scottie S2 320×256 277.692 ms 71.1 s GBR
76 Scottie DX 320×256 1050.300 ms 268.9 s GBR
93 PD50 320×256 388.160 ms 49.7 s YCrCb
99 PD90 320×256 703.040 ms 90.0 s YCrCb
95 PD120 640×496 508.480 ms 126.1 s YCrCb

Martin M1 is the everyday workhorse of QO-100; PD120 is the high-resolution favourite (and the mode the ISS uses for its SSTV events). Both have been verified end-to-end against our decoder.

4. From pixels to tones

The entire mapping between image and audio is one linear formula:

f(pixel) = 1500 Hz + (value / 255) × 800 Hz
Pixel value Meaning Tone
0 black 1500 Hz
64 dark grey 1700 Hz
128 mid grey 1900 Hz
192 light grey 2100 Hz
255 white 2300 Hz

Two more tones live outside the picture range, which is what makes them unmistakable to a receiver:

1200 Hz   sync pulse — "a new line starts here" (below black: cannot be a pixel)
1900 Hz   leader tone — the calibration reference in the header

The encoder walks the image line by line, left to right; during a scan, the instantaneous frequency at time t IS the brightness of the pixel at position x = t / scan_time × width. Note what this implies: within a line, the signal is analog — there is no pixel clock on the air. Our 320 pixels become 320 consecutive tiny tone steps, but a receiver is free to sample the sweep as finely or coarsely as it likes. Vertical resolution, in contrast, is rigidly fixed by the mode's line count.

5. Anatomy of the generated signal

The encoder emits five acts in strict order. Here is the complete timeline (values from the real PD120 run included in this repository):

t=0.00        0.80         1.41    1.71                        127.81   128.11 s
├─ VOX tones ─┼─ leader ───┼─ VIS ─┼─ video: 248 scan lines ───┼─ tail ─┤
│  8 × 100 ms │ 1900 Hz    │ 10bit │ one line = 508.480 ms     │ 1500Hz │

5.1 VOX tones (t = 0.00 – 0.80 s)

Eight 100 ms tones — 1900, 1500, 1900, 1500, 2300, 1500, 2300, 1500 Hz — played before anything else. Their job is purely practical: a radio set to VOX (voice-operated transmit) keys up when it hears audio; these tones wake it so the header that follows is not clipped. Receivers ignore them.

5.2 Calibration leader (t = 0.80 – 1.41 s)

freq
1900 ┤ ████████████ ▁▁ ████████████      ← 300 ms + 300 ms of 1900 Hz
1200 ┤              ▓▓                   ← 10 ms break
     └──────────────────────────────► t

A steady 1900 Hz, a 10 ms dip to 1200 Hz, and 1900 Hz again. It announces "an SSTV picture is coming" and — crucially — provides the frequency reference: a mistuned receiver sees the leader at 1900+X Hz, measures X, and corrects every later tone by it. (Our decoder does exactly this; it recovered a real recording that was off by −109.4 Hz.)

5.3 VIS code (t = 1.41 – 1.71 s)

The VIS (Vertical Interval Signaling) identifies the mode — it is the only digital data in the whole transmission, 1970s-style FSK at 10 bits per 300 ms:

│ start │ b0 │ b1 │ b2 │ b3 │ b4 │ b5 │ b6 │ parity │ stop │   30 ms each
│ 1200  │      7 data bits, LSB first        │ even   │ 1200 │
│       │      1100 Hz = 1,  1300 Hz = 0             │      │

For PD120 the value is 95 = 1011111₂; transmitted LSB-first with an even parity bit. Any receiver reads these ten tones and instantly knows the image size, line timing and colour plan — that is why the two sides of an SSTV contact never have to negotiate anything.

5.4 Scan lines — where the picture lives

Each mode repeats one rigid line template. Martin M1 (GBR, one image line per transmission line):

│sync │porch│  G scan     │sep │  B scan     │sep │  R scan     │sep │
│1200 │1500 │ 146.432 ms  │1500│ 146.432 ms  │1500│ 146.432 ms  │1500│
│4.862│0.572│             │.572│             │.572│             │.572│
└─────┴─────┴─────────────┴────┴─────────────┴────┴─────────────┴────┘
◄──────────────────── 446.446 ms  × 256 lines ────────────────────►

PD120 (YCrCb, two image lines per transmission line):

│ sync  │porch│  Y0 scan   │  Cr scan   │  Cb scan   │  Y1 scan   │
│ 1200  │1500 │ 121.6 ms   │ 121.6 ms   │ 121.6 ms   │ 121.6 ms   │
│ 20 ms │2.08 │ line 2i    │  shared    │  shared    │ line 2i+1  │
└───────┴─────┴────────────┴────────────┴────────────┴────────────┘
◄──────────────────── 508.480 ms  × 248 lines ────────────────────►

The sync pulse (1200 Hz — below black, unmistakable) lets the receiver re-align at every line; without it a 0.01 % clock error would shear the image into a slant. The porch and separators are short rests at black level (1500 Hz) between scans. The encoder's rule for them is simple: any instant that is neither sync nor scan is filled with 1500 Hz.

One subtlety worth knowing: in the Scottie family the sync pulse sits in the middle of the line (before the R scan), not at the start. The MODES table expresses this with a single parameter (sync_pos_ms), so the same code emits both layouts.

5.5 Tail (t = 127.81 – 128.11 s)

300 ms of 1500 Hz after the last line. It exists for a humble reason: if the WAV ended exactly at the final line's last sample, a decoder checking "is this line fully inside the recording?" would discard it. The tail guarantees the last line survives.

6. Colour: GBR and YCrCb

The signal only ever says "this bright, now this bright…". Colour is a convention about how consecutive scans are interpreted:

Martin/Scottie — GBR sequential. Three scans per line carry the line's green, blue and red planes, in that order (green first: the human eye is most sensitive to it). Simple, robust, three full-resolution channels.

PD family — YCrCb with shared chroma. Four scans per transmission line: Y0, Cr, Cb, Y1. The two Y scans are the brightness of two consecutive image lines; Cr and Cb carry the colour of both lines at once. The encoder computes them with the standard ITU-R BT.601 transform:

Y  =  0.299·R + 0.587·G + 0.114·B          (brightness)
Cr = 128 + 0.713·(R − Y)                   (red-difference)
Cb = 128 + 0.564·(B − Y)                   (blue-difference)

and averages Cr/Cb over the line pair. This is chroma subsampling — the same insight JPEG and every video codec use: the eye needs brightness detail far more than colour detail, so spend airtime on Y. It is how PD120 fits four times Martin M1's pixels into barely more airtime.

7. The synthesis engine

This is the low-level heart of the encoder — how a frequency plan becomes actual audio samples.

7.1 Plan first, synthesize once

The encoder never generates audio while walking the image. It first builds a complete plan: two parallel arrays, durations[] (ms) and frequencies[] (Hz) — about 635,000 segments for a PD120 frame (one per pixel, plus headers and syncs). Only when the plan is complete does one single pass turn it into sound. This separation is what makes the next two guarantees easy.

7.2 Continuous phase — why it matters

The naive approach — generate each tone as sin(2πft) and concatenate — fails audibly and visibly: at every boundary the sine restarts at an arbitrary phase, producing a click. A click is a broadband splash: on the waterfall your clean 2.3 kHz signal grows spurs across the whole passband, and neighbours on the transponder are rightly annoyed.

The fix is to synthesize the phase, not the tones:

phase[n] = phase[n−1] + 2π · f[n] / fs        (a running sum)
x[n]     = A · sin(phase[n])

Frequency may jump instantly (1500 → 2300 Hz between pixels is fine — that is FM), but phase never jumps: the sine always continues from where it was. One numpy.cumsum over the whole signal implements this exactly.

7.3 Sample-accurate timing

Segment boundaries are computed in absolute time (a cumulative sum of the exact millisecond durations), then each output sample looks up which segment it falls into (numpy.searchsorted). No boundary is ever rounded and carried forward, so timing error cannot accumulate — after 248 lines the last sync is still exactly where the mode table says. Rounding per sample is bounded by one sample period; at the output rate of 48 kHz one PD120 pixel spans ~22 samples, making that error invisible.

7.4 Finishing touches

  • 10 ms raised-cosine ramps at both ends (no key-click on the air),
  • peak amplitude 0.8 (headroom against clipping in sound cards and transmitter audio chains),
  • 16-bit mono WAV — playable by anything ever made.

8. Code walkthrough

sstv_encoder.py is a single file, read top to bottom:

Section What it does
MODES import the timing table is imported from the decoder (../sstv-decoder/sstv_decoder.py) — one source of truth for both directions; a mode added there is instantly encodable and decodable
px2f() the pixel→frequency formula from §4
class Plan the duration/frequency arrays: tone() appends one constant segment, pixels() appends a whole scan (one segment per pixel)
add_header() VOX tones + leader + VIS bits (computes the even parity itself), returns the exact timestamps of each landmark
add_line() one transmission line: places sync and scans at their absolute offsets from the mode table, fills every gap with 1500 Hz
synthesize() §7: absolute boundaries → per-sample frequency → cumsum phase → sine, ramps
encode_png() orchestration: open PNG (reject anything else), scale with Lanczos, GBR split or BT.601 conversion per mode, build the full plan
plot_freq_analysis() the annotated spectrogram of §9 — marks come from the plan itself, not from measurement
main() writes WAV + meta + analysis, prints the report

Roughly 280 lines including comments. There is no state hidden anywhere: image in, three files out.

9. Outputs and the frequency analysis

Every run produces, under data/ (named after the input image):

File Content
data/<name>_sstv.wav the transmission — 48 kHz mono 16-bit
data/<name>_meta.txt the full report (also printed to the console)
data/<name>_freq_analysis.png annotated spectrogram of the generated signal

The real report for this repository's sample (misc/ta2ldv-sample.png → PD120):

girdi : misc/ta2ldv-sample.png (640x496)
mod   : PD120 (VIS 95), renk PD, 640x496
boyut : olcekleme yok (boyut birebir)
sure  : 128.1 s @ 48000 Hz (mono 16-bit, tepe genlik 0.80)
plan  : 635398 segment, surekli fazli FM

-- sinyal plani --
  VOX tonlari    : t=0.00 - 0.80 s
  lider + break  : t=0.80 - 1.41 s (1900 Hz, ofset referansi)
  VIS kodu       : t=1.41 - 1.71 s (kod 95, cift parite)
  video          : t=1.71 - 127.81 s (248 iletim satiri, satir 508.480 ms)
  kuyruk         : t=127.81 - 128.11 s (1500 Hz)

The generated audio itself: data/ta2ldv-sample_sstv.wav (GitHub cannot embed an audio player in a README, but the link downloads the file — play it and you will hear the famous SSTV "singing").

The frequency analysis — the spectrogram of the WAV we just synthesized, with the reference tones (dotted white) and the plan's landmarks (dashed red) drawn on:

Frequency analysis of the generated signal

How to read it: time runs left to right, audio frequency bottom to top, brightness is energy. The picture lives between the siyah 1500 (black) and beyaz 2300 (white) lines; every vertical slice of that band is one moment of the scan. The regular comb below at 1200 Hz is the sync train — 248 metronome ticks. Because the encoder planned this signal, the red marks are exact by construction; on the decoder side the same picture has to be recovered by measurement. Comparing the two analyses side by side is the fastest way to understand the whole system.

10. Closed-loop verification

The killer feature of owning both ends of the chain: encode, then decode your own signal, and compare.

python3 sstv_encoder.py misc/ta2ldv-sample.png 95
python3 ../sstv-decoder/sstv_decoder.py data/ta2ldv-sample_sstv.wav

Decoder verdict on our generated WAV:

VIS            : 95 (parite OK) -> PD120
frekans ofseti : -0.2 Hz
satir suresi   : nominal 508.480 ms, olculen 508.480 ms (egiklik +0.000%)
hizalama       : 248/248 satir sync ile hizalandi

The input image, and what comes back after the full PNG → audio → PNG round trip:

Input (misc/ta2ldv-sample.png) Round trip (misc/ta2ldv-sample_decoded.png)
input round trip

Mean pixel difference: ~19/255, concentrated entirely at letter edges — the natural smoothing of an analog scan (a PD120 pixel lasts ~0.19 ms; sharp black→white transitions acquire a 1–2 pixel grey halo). Every word remains readable. Flat areas are essentially lossless.

11. Transmitting on the air

The WAV is indistinguishable from the output of MMSSTV or QSSTV, so standard operating practice applies:

  • Feed the audio into the transceiver electrically (sound-card interface) rather than acoustically; set the mode to USB.
  • Keep audio drive modest — watch the ALC. Overdriving distorts the tone frequencies, and frequency is the picture.
  • The built-in VOX tones will key a VOX-enabled rig automatically.
  • On QO-100, transmit within the image segment of the narrow-band transponder band plan and listen first — one image occupies the frequency for ~2 minutes.
  • Identify per your licence. A picture transmission does not replace a callsign — put your callsign in the image (large and bold) like every SSTV operator does.

Proof: our signal over the satellite

We did exactly this. The WAV in this repository (data/ta2ldv-sample_sstv.wav) was uplinked to QO-100 and received back through the IS0GRB WebSDR (http://websdr.is0grb.it:8901/) at 10489620 kHz. The screenshot below captures the recording session: the bright, rhythmically wobbling trace at the SSTV marker is this encoder's signal after a ~76,000 km round trip through a geostationary satellite, arriving at 18 dB SNR — every wobble cycle you see is one PD120 scan line from §5.4:

Our encoded signal on the air, via QO-100

12. Glossary

Term Meaning
SSTV Slow-Scan Television — still images over a 3 kHz voice channel
Mode a fixed recipe of image size + line timing + colour plan (Martin M1, PD120, …)
VIS code 7-bit mode identifier FSK'd in the header (44 = Martin M1, 95 = PD120)
Leader the 1900 Hz calibration tone opening every transmission; the receiver's frequency reference
Break the 10 ms 1200 Hz dip splitting the two leader halves
VOX tones courtesy tones before the header that key voice-operated transmitters
Sync pulse 1200 Hz marker starting each line (Scottie: mid-line); the receiver's timing reference
Porch / separator short black-level (1500 Hz) rests around sync and between scans
Scan one channel of one line swept as a continuous frequency curve
GBR Martin/Scottie colour order: green, blue, red full-resolution scans
YCrCb brightness + two colour-difference channels (BT.601); PD's colour space
Chroma subsampling sharing one Cr/Cb pair between two lines — colour detail traded for airtime
FM frequency modulation — information carried by where the tone is, not how loud
Continuous phase synthesis rule: frequency may jump, phase never does (no clicks, no splatter)
Slant image shear caused by sample-clock mismatch; measured and cancelled by the decoder
Martin M1 320×256 GBR mode by Martin Emmerson G3OQD; QO-100's daily driver (VIS 44)
PD120 640×496 YCrCb mode; high-resolution favourite, used by ISS events (VIS 95)
QO-100 Es'hail-2, the first geostationary amateur-radio satellite
VOX voice-operated transmit — the radio keys itself when it hears audio
USB upper sideband — the SSB flavour SSTV audio is transmitted in

13. Files and requirements

File Role
sstv_encoder.py the whole encoder (single file, numpy/scipy/Pillow)
misc/ta2ldv-sample.png sample input image (640×496 — PD120-native)
misc/ta2ldv-sample_decoded.png the round-trip result from §10
misc/sstv_encoded_air.png our signal on the air — QO-100 via IS0GRB WebSDR, 10489620 kHz (§11)
data/ outputs: <name>_sstv.wav, <name>_meta.txt, <name>_freq_analysis.png
pip3 install numpy scipy pillow matplotlib

Note: the MODES timing table is imported from the sibling project — sstv-decoder must sit next to this folder (../sstv-decoder/sstv_decoder.py). That is deliberate: one table, two directions, zero drift.

14. References

About

Pure-Python SSTV encoder: turns a PNG image into a transmittable WAV signal (Martin/Scottie/PD modes, QO-100 tested) — with a layer-by-layer guide to how pictures become sound

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages