🇬🇧 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
- What does an SSTV encoder do?
- Quick start
- Input rules and supported modes
- From pixels to tones — the core idea
- Anatomy of the generated signal
- Colour: GBR and YCrCb
- The synthesis engine — low level
- Code walkthrough
- Outputs and the frequency analysis
- Closed-loop verification
- Transmitting on the air
- Glossary
- Files and requirements
- References
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.
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 95The 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.
| 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.
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.
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 │
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.
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.)
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.
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.
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.
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.
This is the low-level heart of the encoder — how a frequency plan becomes actual audio samples.
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.
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.
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.
- 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.
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.
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:
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.
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.wavDecoder 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) |
|---|---|
![]() |
![]() |
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.
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.
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:
| 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 |
| 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 matplotlibNote: 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.
- JL Barber N7CXI, Proposal for SSTV Mode Specifications (Dayton 2000) — the de-facto SSTV timing bible: http://www.barberdsp.com/downloads/Dayton%20Paper.pdf
- SSTV Handbook: https://www.sstv-handbook.com/
- Signal Identification Wiki — SSTV: https://www.sigidwiki.com/wiki/SSTV
- ITU-R BT.601 (the YCrCb transform used by the PD modes)
- AMSAT-DL, QO-100 band plan: https://amsat-dl.org/en/qo-100-nb-transponder-bandplan-and-operating-guidelines/
- Sister projects: sstv-decoder · qo100-beacon-decoder · ft8-ft4-decoder



