PySSTV 0.5.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- PySSTV-0.5.5.dist-info/LICENSE.txt +22 -0
- PySSTV-0.5.5.dist-info/METADATA +95 -0
- PySSTV-0.5.5.dist-info/RECORD +23 -0
- PySSTV-0.5.5.dist-info/WHEEL +5 -0
- PySSTV-0.5.5.dist-info/entry_points.txt +2 -0
- PySSTV-0.5.5.dist-info/top_level.txt +1 -0
- pysstv/__init__.py +3 -0
- pysstv/__main__.py +92 -0
- pysstv/color.py +205 -0
- pysstv/examples/__init__.py +3 -0
- pysstv/examples/codegen.py +167 -0
- pysstv/examples/get_floats.py +22 -0
- pysstv/examples/get_freq_bits.py +23 -0
- pysstv/examples/gimp-plugin.py +253 -0
- pysstv/examples/overlay.py +17 -0
- pysstv/examples/pyaudio_sstv.py +51 -0
- pysstv/examples/repeater.py +73 -0
- pysstv/grayscale.py +40 -0
- pysstv/sstv.py +136 -0
- pysstv/tests/__init__.py +3 -0
- pysstv/tests/common.py +9 -0
- pysstv/tests/test_color.py +44 -0
- pysstv/tests/test_sstv.py +79 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- encoding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
# copy to ~/.gimp-2.8/plug-ins/
|
|
5
|
+
# dependencies: GIMP 2.8, python-imaging-tk, python-pyaudio
|
|
6
|
+
|
|
7
|
+
from gimpfu import register, main, pdb, PF_BOOL, PF_STRING, PF_RADIO, CLIP_TO_IMAGE
|
|
8
|
+
from PIL import Image, ImageTk
|
|
9
|
+
from tkinter import Tk, Canvas, Button, Checkbutton, IntVar, Frame, LEFT, NW
|
|
10
|
+
from pysstv import __main__ as pysstv_main
|
|
11
|
+
from pysstv.examples.pyaudio_sstv import PyAudioSSTV
|
|
12
|
+
from pysstv.sstv import SSTV
|
|
13
|
+
from itertools import repeat
|
|
14
|
+
from threading import Thread
|
|
15
|
+
from queue import Queue, Empty
|
|
16
|
+
from time import sleep
|
|
17
|
+
import gimp, os
|
|
18
|
+
|
|
19
|
+
MODULE_MAP = pysstv_main.build_module_map()
|
|
20
|
+
|
|
21
|
+
class AudioThread(Thread):
|
|
22
|
+
def __init__(self, sstv, parent):
|
|
23
|
+
Thread.__init__(self)
|
|
24
|
+
self.pas = PyAudioSSTV(sstv)
|
|
25
|
+
self.parent = parent
|
|
26
|
+
|
|
27
|
+
def run(self):
|
|
28
|
+
self.pas.execute()
|
|
29
|
+
self.parent.audio_thread_ended()
|
|
30
|
+
|
|
31
|
+
def stop(self):
|
|
32
|
+
if self.pas is not None:
|
|
33
|
+
self.pas.sampler = []
|
|
34
|
+
self.pas = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Sine1750(SSTV):
|
|
38
|
+
encode_line = None
|
|
39
|
+
|
|
40
|
+
def gen_freq_bits(self):
|
|
41
|
+
return repeat((1750, 1000))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Transmitter(object):
|
|
45
|
+
def __init__(self, sstv, root, progress, set_ptt_pin, ptt_state):
|
|
46
|
+
def encode_line_hooked(line):
|
|
47
|
+
progress.update_image(line)
|
|
48
|
+
return self.original_encode_line(line)
|
|
49
|
+
self.progress = progress
|
|
50
|
+
self.sstv = sstv
|
|
51
|
+
self.original_encode_line = sstv.encode_line
|
|
52
|
+
sstv.encode_line = encode_line_hooked
|
|
53
|
+
self.root = root
|
|
54
|
+
self.tx_enabled = IntVar()
|
|
55
|
+
self.audio_thread = None
|
|
56
|
+
self.stopping = False
|
|
57
|
+
self.set_ptt_pin = set_ptt_pin
|
|
58
|
+
self.ptt_state = ptt_state
|
|
59
|
+
|
|
60
|
+
def set_ptt(self, state):
|
|
61
|
+
if self.set_ptt_pin is None:
|
|
62
|
+
return
|
|
63
|
+
if not state:
|
|
64
|
+
sleep(0.2)
|
|
65
|
+
self.set_ptt_pin(state != self.ptt_state)
|
|
66
|
+
if state:
|
|
67
|
+
sleep(0.2)
|
|
68
|
+
|
|
69
|
+
def start_stop_tx(self):
|
|
70
|
+
if self.tx_enabled.get():
|
|
71
|
+
self.stopping = False
|
|
72
|
+
self.audio_thread = AudioThread(self.sstv, self)
|
|
73
|
+
self.set_ptt(True)
|
|
74
|
+
self.audio_thread.start()
|
|
75
|
+
else:
|
|
76
|
+
self.stop()
|
|
77
|
+
if self.progress is not None:
|
|
78
|
+
self.progress.update_image()
|
|
79
|
+
|
|
80
|
+
def stop(self):
|
|
81
|
+
if self.audio_thread is not None:
|
|
82
|
+
self.stopping = True
|
|
83
|
+
self.audio_thread.stop()
|
|
84
|
+
self.set_ptt(False)
|
|
85
|
+
|
|
86
|
+
def audio_thread_ended(self):
|
|
87
|
+
if not self.stopping:
|
|
88
|
+
self.set_ptt(False)
|
|
89
|
+
self.tx_enabled.set(0)
|
|
90
|
+
|
|
91
|
+
def close(self):
|
|
92
|
+
self.root.destroy()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class CanvasUpdater(Thread):
|
|
96
|
+
def __init__(self, progress):
|
|
97
|
+
Thread.__init__(self)
|
|
98
|
+
self.progress = progress
|
|
99
|
+
self.queue = Queue()
|
|
100
|
+
self.should_run = True
|
|
101
|
+
|
|
102
|
+
def stop(self):
|
|
103
|
+
self.should_run = False
|
|
104
|
+
|
|
105
|
+
def update_image(self, line=None):
|
|
106
|
+
self.queue.put(line)
|
|
107
|
+
|
|
108
|
+
def run(self):
|
|
109
|
+
while self.should_run:
|
|
110
|
+
try:
|
|
111
|
+
self.progress.update_image(self.queue.get(timeout=0.5))
|
|
112
|
+
except Empty:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ProgressCanvas(Canvas):
|
|
117
|
+
def __init__(self, master, image):
|
|
118
|
+
self.height_ratio = 1
|
|
119
|
+
width, height = image.size
|
|
120
|
+
pixels = image.load()
|
|
121
|
+
RED, GREEN, BLUE = list(range(3))
|
|
122
|
+
self.colors = ['#{0:02x}{1:02x}{2:02x}'.format(
|
|
123
|
+
contrast(sum(pixels[x, y][RED] for x in range(width)) / width),
|
|
124
|
+
contrast(sum(pixels[x, y][GREEN] for x in range(width)) / width),
|
|
125
|
+
contrast(sum(pixels[x, y][BLUE] for x in range(width)) / width))
|
|
126
|
+
for y in range(height)]
|
|
127
|
+
if height / float(width) > 1.5:
|
|
128
|
+
width *= 2
|
|
129
|
+
elif width < 200:
|
|
130
|
+
width *= 2
|
|
131
|
+
height *= 2
|
|
132
|
+
self.height_ratio = 2
|
|
133
|
+
if (width, height) != image.size:
|
|
134
|
+
image = image.resize((width, height))
|
|
135
|
+
Canvas.__init__(self, master, width=width, height=height)
|
|
136
|
+
self.tk_img = ImageTk.PhotoImage(image)
|
|
137
|
+
self.update_image()
|
|
138
|
+
self.pack()
|
|
139
|
+
|
|
140
|
+
def update_image(self, line=None):
|
|
141
|
+
image = self.tk_img
|
|
142
|
+
self.create_image(0, 0, anchor=NW, image=image)
|
|
143
|
+
if line is not None:
|
|
144
|
+
fill = self.colors[line]
|
|
145
|
+
line *= self.height_ratio
|
|
146
|
+
self.create_line(0, line, image.width(), line, fill=fill)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def contrast(value):
|
|
150
|
+
if 80 < value < 160:
|
|
151
|
+
return value + (80 if value < 120 else -80)
|
|
152
|
+
else:
|
|
153
|
+
return 255 - value
|
|
154
|
+
|
|
155
|
+
def transmit_current_image(image, drawable, mode, vox, fskid, ptt_port, ptt_pin, ptt_state):
|
|
156
|
+
sstv = MODULE_MAP[mode]
|
|
157
|
+
if ptt_port is not None:
|
|
158
|
+
from serial import Serial
|
|
159
|
+
set_ptt_pin = getattr(Serial(ptt_port), 'set' + ptt_pin)
|
|
160
|
+
set_ptt_pin(ptt_state)
|
|
161
|
+
else:
|
|
162
|
+
set_ptt_pin = None
|
|
163
|
+
pil_img = match_image_with_sstv_mode(image_gimp_to_pil(image), sstv)
|
|
164
|
+
root = Tk()
|
|
165
|
+
cu = CanvasUpdater(ProgressCanvas(root, pil_img))
|
|
166
|
+
cu.start()
|
|
167
|
+
tm = Transmitter(init_sstv(sstv, pil_img, vox, fskid), root, cu, set_ptt_pin, ptt_state)
|
|
168
|
+
tm1750 = Transmitter(Sine1750(None, 44100, 16), None, None, set_ptt_pin, ptt_state)
|
|
169
|
+
buttons = Frame(root)
|
|
170
|
+
for text, tram in (('TX', tm), ('1750 Hz', tm1750)):
|
|
171
|
+
Checkbutton(buttons, text=text, indicatoron=False, padx=5, pady=5,
|
|
172
|
+
variable=tram.tx_enabled, command=tram.start_stop_tx).pack(side=LEFT)
|
|
173
|
+
Button(buttons, text="Close", command=tm.close).pack(side=LEFT)
|
|
174
|
+
buttons.pack()
|
|
175
|
+
root.mainloop()
|
|
176
|
+
for obj in (tm, tm1750, cu):
|
|
177
|
+
obj.stop()
|
|
178
|
+
|
|
179
|
+
def image_gimp_to_pil(image):
|
|
180
|
+
try:
|
|
181
|
+
sandbox = image.duplicate()
|
|
182
|
+
for layer in sandbox.layers:
|
|
183
|
+
if not layer.visible:
|
|
184
|
+
sandbox.remove_layer(layer)
|
|
185
|
+
sandbox.merge_visible_layers(CLIP_TO_IMAGE)
|
|
186
|
+
layer = sandbox.layers[0]
|
|
187
|
+
if not layer.is_rgb:
|
|
188
|
+
pdb.gimp_image_convert_rgb(sandbox)
|
|
189
|
+
if layer.has_alpha:
|
|
190
|
+
pdb.gimp_layer_flatten(layer)
|
|
191
|
+
w, h = layer.width, layer.height
|
|
192
|
+
pixels = layer.get_pixel_rgn(0, 0, w, h)[:, :] # all pixels
|
|
193
|
+
return Image.frombuffer('RGB', (w, h), pixels, 'raw', 'RGB', 0, 0)
|
|
194
|
+
finally:
|
|
195
|
+
gimp.delete(sandbox)
|
|
196
|
+
|
|
197
|
+
def match_image_with_sstv_mode(image, mode):
|
|
198
|
+
mode_size = mode.WIDTH, mode.HEIGHT
|
|
199
|
+
if image.size != mode_size:
|
|
200
|
+
image = image.resize(mode_size, Image.ANTIALIAS)
|
|
201
|
+
if 'grayscale' in mode.__module__:
|
|
202
|
+
image = image.convert('LA').convert('RGB')
|
|
203
|
+
return image
|
|
204
|
+
|
|
205
|
+
def init_sstv(mode, image, vox, fskid):
|
|
206
|
+
s = mode(image, 44100, 16)
|
|
207
|
+
s.vox_enabled = vox
|
|
208
|
+
if fskid:
|
|
209
|
+
s.add_fskid_text(fskid)
|
|
210
|
+
return s
|
|
211
|
+
|
|
212
|
+
def get_serial_ports():
|
|
213
|
+
try:
|
|
214
|
+
if os.name == 'nt':
|
|
215
|
+
from serial.tools.list_ports_windows import comports
|
|
216
|
+
elif os.name == 'posix':
|
|
217
|
+
from serial.tools.list_ports_posix import comports
|
|
218
|
+
else:
|
|
219
|
+
raise ImportError("Sorry: no implementation for your"
|
|
220
|
+
"platform ('%s') available" % (os.name,))
|
|
221
|
+
except ImportError:
|
|
222
|
+
yield "(couldn't import PySerial)", None
|
|
223
|
+
else:
|
|
224
|
+
yield "(disabled)", None
|
|
225
|
+
for port, desc, _ in comports():
|
|
226
|
+
yield '{0} ({1})'.format(port, desc.strip()), port
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
register(
|
|
230
|
+
"pysstv_for_gimp",
|
|
231
|
+
"PySSTV for GIMP",
|
|
232
|
+
"Transmits the current image using PySSTV",
|
|
233
|
+
"Andras Veres-Szentkiralyi",
|
|
234
|
+
"Andras Veres-Szentkiralyi",
|
|
235
|
+
"November 2013",
|
|
236
|
+
"<Image>/PySSTV/Transmit...",
|
|
237
|
+
"*",
|
|
238
|
+
[
|
|
239
|
+
(PF_RADIO, "mode", "SSTV mode", "MartinM1",
|
|
240
|
+
tuple((n, n) for n in sorted(MODULE_MAP.keys()))),
|
|
241
|
+
(PF_BOOL, "vox", "Include VOX tones", True),
|
|
242
|
+
(PF_STRING, "fskid", "FSK ID", ""),
|
|
243
|
+
(PF_RADIO, "ptt_port", "PTT port", None,
|
|
244
|
+
tuple(get_serial_ports())),
|
|
245
|
+
(PF_RADIO, "ptt_pin", "PTT pin", "RTS",
|
|
246
|
+
tuple((n, n) for n in ("RTS", "DTR"))),
|
|
247
|
+
(PF_BOOL, "ptt_state", "PTT inversion", False),
|
|
248
|
+
],
|
|
249
|
+
[],
|
|
250
|
+
transmit_current_image
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
main()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Demonstrates adding text overlay (callsign, RSV, etc.) using PIL
|
|
5
|
+
See example output received by slowrx at the following URL:
|
|
6
|
+
http://vsza.hu/c6fa52b2c7b20320bdab2da15877f0efbd466e67d37c8d124a557367de9380da.png
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from PIL import Image, ImageFont, ImageDraw
|
|
10
|
+
from pysstv.grayscale import Robot8BW
|
|
11
|
+
|
|
12
|
+
img = Image.open("160x120bw.png")
|
|
13
|
+
font = ImageFont.load_default()
|
|
14
|
+
draw = ImageDraw.Draw(img)
|
|
15
|
+
draw.text((0, 0), "HA5VSA", (255,255,255), font=font)
|
|
16
|
+
sstv = Robot8BW(img, 44100, 16)
|
|
17
|
+
sstv.write_wav("overlay.wav")
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Demonstrates playing the generated samples directly using PyAudio
|
|
5
|
+
Tested on PyAudio 0.2.7 http://people.csail.mit.edu/hubert/pyaudio/
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
from pysstv.sstv import SSTV
|
|
10
|
+
from time import sleep
|
|
11
|
+
from itertools import islice
|
|
12
|
+
import struct, pyaudio
|
|
13
|
+
|
|
14
|
+
class PyAudioSSTV(object):
|
|
15
|
+
def __init__(self, sstv):
|
|
16
|
+
self.pa = pyaudio.PyAudio()
|
|
17
|
+
self.sstv = sstv
|
|
18
|
+
self.fmt = '<' + SSTV.BITS_TO_STRUCT[sstv.bits]
|
|
19
|
+
|
|
20
|
+
def __del__(self):
|
|
21
|
+
self.pa.terminate()
|
|
22
|
+
|
|
23
|
+
def execute(self):
|
|
24
|
+
self.sampler = self.sstv.gen_samples()
|
|
25
|
+
stream = self.pa.open(
|
|
26
|
+
format=self.pa.get_format_from_width(self.sstv.bits // 8),
|
|
27
|
+
channels=1, rate=self.sstv.samples_per_sec, output=True,
|
|
28
|
+
stream_callback=self.callback)
|
|
29
|
+
stream.start_stream()
|
|
30
|
+
while stream.is_active():
|
|
31
|
+
sleep(0.5)
|
|
32
|
+
stream.stop_stream()
|
|
33
|
+
stream.close()
|
|
34
|
+
|
|
35
|
+
def callback(self, in_data, frame_count, time_info, status):
|
|
36
|
+
frames = ''.join(struct.pack(self.fmt, b)
|
|
37
|
+
for b in islice(self.sampler, frame_count))
|
|
38
|
+
return frames, pyaudio.paContinue
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main():
|
|
42
|
+
from PIL import Image
|
|
43
|
+
from pysstv.grayscale import Robot8BW
|
|
44
|
+
|
|
45
|
+
img = Image.open("160x120bw.png")
|
|
46
|
+
sstv = Robot8BW(img, 44100, 16)
|
|
47
|
+
sstv.vox_enabled = True
|
|
48
|
+
PyAudioSSTV(sstv).execute()
|
|
49
|
+
|
|
50
|
+
if __name__ == '__main__':
|
|
51
|
+
main()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Simple repeater that monitors a single directory using inotify, and if
|
|
5
|
+
an image appears, it tries to repeat it on using the PyAudio example,
|
|
6
|
+
trying to match the mode used for receiving it. It can be tested by
|
|
7
|
+
simply copying/linking images to the directory or suing an SSTV
|
|
8
|
+
receiver such as slowrx or QSSTV.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
from pyinotify import WatchManager, Notifier, ProcessEvent, IN_CREATE
|
|
13
|
+
from .pyaudio_sstv import PyAudioSSTV
|
|
14
|
+
from pysstv.color import MartinM1, MartinM2, ScottieS1, ScottieS2
|
|
15
|
+
from pysstv.grayscale import Robot8BW, Robot24BW
|
|
16
|
+
from PIL import Image
|
|
17
|
+
from os import path
|
|
18
|
+
|
|
19
|
+
# matches the abbreviations used by slowrx and QSSTV
|
|
20
|
+
MODE_MAP = {
|
|
21
|
+
'M1': MartinM1,
|
|
22
|
+
'M2': MartinM2,
|
|
23
|
+
'S1': ScottieS1,
|
|
24
|
+
'S2': ScottieS2,
|
|
25
|
+
'R8BW': Robot8BW,
|
|
26
|
+
'R24BW': Robot24BW,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class EventHandler(ProcessEvent):
|
|
30
|
+
def process_IN_CREATE(self, event):
|
|
31
|
+
filename = event.pathname
|
|
32
|
+
print('Found image', filename)
|
|
33
|
+
mode = get_module_for_filename(filename)
|
|
34
|
+
img = Image.open(filename)
|
|
35
|
+
if mode is None:
|
|
36
|
+
mode = get_module_for_image(img)
|
|
37
|
+
if mode is None:
|
|
38
|
+
print('No suitable mode found to repeat', filename)
|
|
39
|
+
return
|
|
40
|
+
print('Repeating image using', mode.__name__)
|
|
41
|
+
sstv = mode(img, 44100, 16)
|
|
42
|
+
sstv.vox_enabled = True
|
|
43
|
+
PyAudioSSTV(sstv).execute()
|
|
44
|
+
|
|
45
|
+
def get_module_for_filename(filename):
|
|
46
|
+
basename, _ = path.splitext(path.basename(filename))
|
|
47
|
+
for mode, module in MODE_MAP.items():
|
|
48
|
+
if mode in basename:
|
|
49
|
+
return module
|
|
50
|
+
|
|
51
|
+
def get_module_for_image(image):
|
|
52
|
+
size = image.size
|
|
53
|
+
for mode in MODE_MAP.values():
|
|
54
|
+
if all(i >= m for i, m in zip(size, (mode.WIDTH, mode.HEIGHT))):
|
|
55
|
+
return mode
|
|
56
|
+
|
|
57
|
+
def main():
|
|
58
|
+
from sys import argv, stderr
|
|
59
|
+
try:
|
|
60
|
+
directory = argv[1]
|
|
61
|
+
except IndexError:
|
|
62
|
+
print("Usage: {0} <directory>".format(argv[0]), file=stderr)
|
|
63
|
+
else:
|
|
64
|
+
watch(directory)
|
|
65
|
+
|
|
66
|
+
def watch(directory):
|
|
67
|
+
wm = WatchManager()
|
|
68
|
+
notifier = Notifier(wm, EventHandler())
|
|
69
|
+
wm.add_watch(directory, IN_CREATE)
|
|
70
|
+
notifier.loop()
|
|
71
|
+
|
|
72
|
+
if __name__ == '__main__':
|
|
73
|
+
main()
|
pysstv/grayscale.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
from __future__ import division
|
|
4
|
+
from pysstv.sstv import SSTV, byte_to_freq
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GrayscaleSSTV(SSTV):
|
|
8
|
+
def on_init(self):
|
|
9
|
+
self.pixels = self.image.convert('LA').load()
|
|
10
|
+
|
|
11
|
+
def gen_image_tuples(self):
|
|
12
|
+
for line in range(self.HEIGHT):
|
|
13
|
+
yield from self.horizontal_sync()
|
|
14
|
+
yield from self.encode_line(line)
|
|
15
|
+
|
|
16
|
+
def encode_line(self, line):
|
|
17
|
+
msec_pixel = self.SCAN / self.WIDTH
|
|
18
|
+
image = self.pixels
|
|
19
|
+
for col in range(self.WIDTH):
|
|
20
|
+
pixel = image[col, line]
|
|
21
|
+
freq_pixel = byte_to_freq(pixel[0])
|
|
22
|
+
yield freq_pixel, msec_pixel
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Robot8BW(GrayscaleSSTV):
|
|
26
|
+
VIS_CODE = 0x02
|
|
27
|
+
WIDTH = 160
|
|
28
|
+
HEIGHT = 120
|
|
29
|
+
SYNC = 7
|
|
30
|
+
SCAN = 60
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Robot24BW(GrayscaleSSTV):
|
|
34
|
+
VIS_CODE = 0x0A
|
|
35
|
+
WIDTH = 320
|
|
36
|
+
HEIGHT = 240
|
|
37
|
+
SYNC = 7
|
|
38
|
+
SCAN = 93
|
|
39
|
+
|
|
40
|
+
MODES = (Robot8BW, Robot24BW)
|
pysstv/sstv.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
from __future__ import division, with_statement
|
|
4
|
+
from math import sin, pi
|
|
5
|
+
from random import random
|
|
6
|
+
from contextlib import closing
|
|
7
|
+
from itertools import cycle, chain
|
|
8
|
+
from array import array
|
|
9
|
+
import wave
|
|
10
|
+
|
|
11
|
+
FREQ_VIS_BIT1 = 1100
|
|
12
|
+
FREQ_SYNC = 1200
|
|
13
|
+
FREQ_VIS_BIT0 = 1300
|
|
14
|
+
FREQ_BLACK = 1500
|
|
15
|
+
FREQ_VIS_START = 1900
|
|
16
|
+
FREQ_WHITE = 2300
|
|
17
|
+
FREQ_RANGE = FREQ_WHITE - FREQ_BLACK
|
|
18
|
+
FREQ_FSKID_BIT1 = 1900
|
|
19
|
+
FREQ_FSKID_BIT0 = 2100
|
|
20
|
+
|
|
21
|
+
MSEC_VIS_START = 300
|
|
22
|
+
MSEC_VIS_SYNC = 10
|
|
23
|
+
MSEC_VIS_BIT = 30
|
|
24
|
+
MSEC_FSKID_BIT = 22
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SSTV(object):
|
|
28
|
+
|
|
29
|
+
def __init__(self, image, samples_per_sec, bits):
|
|
30
|
+
self.image = image
|
|
31
|
+
self.samples_per_sec = samples_per_sec
|
|
32
|
+
self.bits = bits
|
|
33
|
+
self.vox_enabled = False
|
|
34
|
+
self.fskid_payload = ''
|
|
35
|
+
self.nchannels = 1
|
|
36
|
+
self.on_init()
|
|
37
|
+
|
|
38
|
+
def on_init(self):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
BITS_TO_STRUCT = {8: 'b', 16: 'h'}
|
|
42
|
+
|
|
43
|
+
def write_wav(self, filename):
|
|
44
|
+
"""writes the whole image to a Microsoft WAV file"""
|
|
45
|
+
fmt = self.BITS_TO_STRUCT[self.bits]
|
|
46
|
+
data = array(fmt, self.gen_samples())
|
|
47
|
+
if self.nchannels != 1:
|
|
48
|
+
data = array(fmt, chain.from_iterable(
|
|
49
|
+
zip(*([data] * self.nchannels))))
|
|
50
|
+
with closing(wave.open(filename, 'wb')) as wav:
|
|
51
|
+
wav.setnchannels(self.nchannels)
|
|
52
|
+
wav.setsampwidth(self.bits // 8)
|
|
53
|
+
wav.setframerate(self.samples_per_sec)
|
|
54
|
+
wav.writeframes(data)
|
|
55
|
+
|
|
56
|
+
def gen_samples(self):
|
|
57
|
+
"""generates discrete samples from gen_values()
|
|
58
|
+
|
|
59
|
+
performs quantization according to
|
|
60
|
+
the bits per sample value given during construction
|
|
61
|
+
"""
|
|
62
|
+
max_value = 2 ** self.bits
|
|
63
|
+
alias = 1 / max_value
|
|
64
|
+
amp = max_value // 2
|
|
65
|
+
lowest = -amp
|
|
66
|
+
highest = amp - 1
|
|
67
|
+
alias_cycle = cycle((alias * (random() - 0.5) for _ in range(1024)))
|
|
68
|
+
for value, alias_item in zip(self.gen_values(), alias_cycle):
|
|
69
|
+
sample = int(value * amp + alias_item)
|
|
70
|
+
yield (lowest if sample <= lowest else
|
|
71
|
+
sample if sample <= highest else highest)
|
|
72
|
+
|
|
73
|
+
def gen_values(self):
|
|
74
|
+
"""generates samples between -1 and +1 from gen_freq_bits()
|
|
75
|
+
|
|
76
|
+
performs sampling according to
|
|
77
|
+
the samples per second value given during construction
|
|
78
|
+
"""
|
|
79
|
+
spms = self.samples_per_sec / 1000
|
|
80
|
+
offset = 0
|
|
81
|
+
samples = 0
|
|
82
|
+
factor = 2 * pi / self.samples_per_sec
|
|
83
|
+
sample = 0
|
|
84
|
+
for freq, msec in self.gen_freq_bits():
|
|
85
|
+
samples += spms * msec
|
|
86
|
+
tx = int(samples)
|
|
87
|
+
freq_factor = freq * factor
|
|
88
|
+
for sample in range(tx):
|
|
89
|
+
yield sin(sample * freq_factor + offset)
|
|
90
|
+
offset += (sample + 1) * freq_factor
|
|
91
|
+
samples -= tx
|
|
92
|
+
|
|
93
|
+
def gen_freq_bits(self):
|
|
94
|
+
"""generates tuples (freq, msec) that describe a sine wave segment
|
|
95
|
+
|
|
96
|
+
frequency "freq" in Hz and duration "msec" in ms
|
|
97
|
+
"""
|
|
98
|
+
if self.vox_enabled:
|
|
99
|
+
for freq in (1900, 1500, 1900, 1500, 2300, 1500, 2300, 1500):
|
|
100
|
+
yield freq, 100
|
|
101
|
+
yield FREQ_VIS_START, MSEC_VIS_START
|
|
102
|
+
yield FREQ_SYNC, MSEC_VIS_SYNC
|
|
103
|
+
yield FREQ_VIS_START, MSEC_VIS_START
|
|
104
|
+
yield FREQ_SYNC, MSEC_VIS_BIT # start bit
|
|
105
|
+
vis = self.VIS_CODE
|
|
106
|
+
num_ones = 0
|
|
107
|
+
for _ in range(7):
|
|
108
|
+
bit = vis & 1
|
|
109
|
+
vis >>= 1
|
|
110
|
+
num_ones += bit
|
|
111
|
+
bit_freq = FREQ_VIS_BIT1 if bit == 1 else FREQ_VIS_BIT0
|
|
112
|
+
yield bit_freq, MSEC_VIS_BIT
|
|
113
|
+
parity_freq = FREQ_VIS_BIT1 if num_ones % 2 == 1 else FREQ_VIS_BIT0
|
|
114
|
+
yield parity_freq, MSEC_VIS_BIT
|
|
115
|
+
yield FREQ_SYNC, MSEC_VIS_BIT # stop bit
|
|
116
|
+
yield from self.gen_image_tuples()
|
|
117
|
+
for fskid_byte in map(ord, self.fskid_payload):
|
|
118
|
+
for _ in range(6):
|
|
119
|
+
bit = fskid_byte & 1
|
|
120
|
+
fskid_byte >>= 1
|
|
121
|
+
bit_freq = FREQ_FSKID_BIT1 if bit == 1 else FREQ_FSKID_BIT0
|
|
122
|
+
yield bit_freq, MSEC_FSKID_BIT
|
|
123
|
+
|
|
124
|
+
def gen_image_tuples(self):
|
|
125
|
+
return []
|
|
126
|
+
|
|
127
|
+
def add_fskid_text(self, text):
|
|
128
|
+
self.fskid_payload += '\x20\x2a{0}\x01'.format(
|
|
129
|
+
''.join(chr(ord(c) - 0x20) for c in text))
|
|
130
|
+
|
|
131
|
+
def horizontal_sync(self):
|
|
132
|
+
yield FREQ_SYNC, self.SYNC
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def byte_to_freq(value):
|
|
136
|
+
return FREQ_BLACK + FREQ_RANGE * value / 255
|
pysstv/tests/__init__.py
ADDED
pysstv/tests/common.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from itertools import islice
|
|
3
|
+
|
|
4
|
+
from PIL import Image
|
|
5
|
+
|
|
6
|
+
from pysstv import color
|
|
7
|
+
from pysstv.tests.common import get_asset_filename, load_pickled_asset
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestMartinM1(unittest.TestCase):
|
|
11
|
+
|
|
12
|
+
def setUp(self):
|
|
13
|
+
self.image = Image.new('RGB', (320, 256))
|
|
14
|
+
self.s = color.MartinM1(self.image, 48000, 16)
|
|
15
|
+
lena = Image.open(get_asset_filename('320x256.png'))
|
|
16
|
+
self.lena = color.MartinM1(lena, 48000, 16)
|
|
17
|
+
|
|
18
|
+
def test_gen_freq_bits(self):
|
|
19
|
+
expected = load_pickled_asset("MartinM1_freq_bits")
|
|
20
|
+
actual = list(islice(self.s.gen_freq_bits(), 0, 1000))
|
|
21
|
+
self.assertEqual(expected, actual)
|
|
22
|
+
|
|
23
|
+
def test_gen_freq_bits_lena(self):
|
|
24
|
+
expected = load_pickled_asset("MartinM1_freq_bits_lena")
|
|
25
|
+
actual = list(islice(self.lena.gen_freq_bits(), 0, 1000))
|
|
26
|
+
self.assertEqual(expected, actual)
|
|
27
|
+
|
|
28
|
+
def test_encode_line(self):
|
|
29
|
+
zeroth = list(self.s.encode_line(0))
|
|
30
|
+
first = list(self.s.encode_line(1))
|
|
31
|
+
tenth = list(self.s.encode_line(10))
|
|
32
|
+
eleventh = list(self.s.encode_line(11))
|
|
33
|
+
|
|
34
|
+
self.assertEqual(zeroth, first)
|
|
35
|
+
self.assertEqual(tenth, eleventh)
|
|
36
|
+
self.assertEqual(zeroth, eleventh)
|
|
37
|
+
|
|
38
|
+
def test_encode_line_lena(self):
|
|
39
|
+
self.maxDiff = None
|
|
40
|
+
line_numbers = [1, 10, 100]
|
|
41
|
+
for line in line_numbers:
|
|
42
|
+
expected = load_pickled_asset("MartinM1_encode_line_lena{0}".format(line))
|
|
43
|
+
actual = list(self.lena.encode_line(line))
|
|
44
|
+
self.assertEqual(expected, actual)
|