Compare commits

..

No commits in common. "master" and "v0.2.6" have entirely different histories.

19 changed files with 107 additions and 510 deletions

View file

@ -1,40 +0,0 @@
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
name: Python package
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11"]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install flake8 pytest
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
pytest

1
.gitignore vendored
View file

@ -1 +0,0 @@
stb_image.h

View file

@ -1,9 +1,6 @@
language: python language: python
python: python:
- "3.6" - "2.7"
- "3.7"
- "3.8"
- "3.9"
sudo: false sudo: false
install: "pip install -r requirements.txt" install: "pip install -r requirements.txt"
script: nosetests script: nosetests

View file

@ -1,6 +1,8 @@
SSTV generator in pure Python SSTV generator in pure Python
============================= =============================
[![Build Status](https://travis-ci.org/dnet/pySSTV.svg?branch=master)](https://travis-ci.org/dnet/pySSTV)
PySSTV generates SSTV modulated WAV files from any image that PIL can open PySSTV generates SSTV modulated WAV files from any image that PIL can open
(PNG, JPEG, GIF, and many others). These WAV files then can be played by any (PNG, JPEG, GIF, and many others). These WAV files then can be played by any
audio player connected to a shortwave radio for example. audio player connected to a shortwave radio for example.
@ -14,10 +16,8 @@ Command line usage
$ python -m pysstv -h $ python -m pysstv -h
usage: __main__.py [-h] usage: __main__.py [-h]
[--mode {MartinM1,MartinM2,ScottieS1,ScottieS2,ScottieDX,Robot36,PasokonP3,PasokonP5,PasokonP7,PD90,PD120,PD160,PD180,PD240,PD290,WraaseSC2120,WraaseSC2180,Robot8BW,Robot24BW}] [--mode {MartinM2,MartinM1,Robot24BW,ScottieS2,ScottieS1,Robot8BW,PasokonP3,PasokonP5,PasokonP7}]
[--rate RATE] [--bits BITS] [--vox] [--fskid FSKID] [--rate RATE] [--bits BITS]
[--chan CHAN] [--resize] [--keep-aspect-ratio]
[--keep-aspect] [--resample {nearest,bicubic,lanczos}]
image.png output.wav image.png output.wav
Converts an image to an SSTV modulated WAV file. Converts an image to an SSTV modulated WAV file.
@ -26,23 +26,12 @@ Command line usage
image.png input image file name image.png input image file name
output.wav output WAV file name output.wav output WAV file name
options: optional arguments:
-h, --help show this help message and exit -h, --help show this help message and exit
--mode {MartinM1,MartinM2,ScottieS1,ScottieS2,ScottieDX,Robot36,PasokonP3,PasokonP5,PasokonP7,PD90,PD120,PD160,PD180,PD240,PD290,WraaseSC2120,WraaseSC2180,Robot8BW,Robot24BW} --mode {MartinM2,MartinM1,Robot24BW,ScottieS2,ScottieS1,Robot8BW,PasokonP3,PasokonP5,PasokonP7}
image mode (default: Martin M1) image mode (default: Martin M1)
--rate RATE sampling rate (default: 48000) --rate RATE sampling rate (default: 48000)
--bits BITS bits per sample (default: 16) --bits BITS bits per sample (default: 16)
--vox add VOX tones at the beginning
--fskid FSKID add FSKID at the end
--chan CHAN number of channels (default: mono)
--resize resize the image to the correct size
--keep-aspect-ratio keep the original aspect ratio when resizing
(and cut off excess pixels)
--keep-aspect keep the original aspect ratio when resizing
(not cut off excess pixels)
--resample {nearest,bicubic,lanczos}
which resampling filter to use for resizing
(see Pillow documentation)
Python interface Python interface
---------------- ----------------
@ -82,5 +71,5 @@ Useful links
Dependencies Dependencies
------------ ------------
- Python 3.5 or later - Python 2.7 (tested on 2.7.5)
- Python Imaging Library (Debian/Ubuntu package: `python3-pil`) - Python Imaging Library (Debian/Ubuntu package: `python-imaging`)

View file

@ -1,3 +0,0 @@
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

View file

@ -1,6 +1,6 @@
#!/usr/bin/env python #!/usr/bin/env python
from __future__ import print_function, division from __future__ import print_function
from PIL import Image from PIL import Image
from argparse import ArgumentParser from argparse import ArgumentParser
from sys import stderr from sys import stderr
@ -30,53 +30,10 @@ def main():
help='add FSKID at the end') help='add FSKID at the end')
parser.add_argument('--chan', dest='chan', type=int, parser.add_argument('--chan', dest='chan', type=int,
help='number of channels (default: mono)') help='number of channels (default: mono)')
parser.add_argument('--resize', dest='resize', action='store_true',
help='resize the image to the correct size')
parser.add_argument('--keep-aspect-ratio', dest='keep_aspect_ratio', action='store_true',
help='keep the original aspect ratio when resizing (and cut off excess pixels)')
parser.add_argument('--keep-aspect', dest='keep_aspect', action='store_true',
help='keep the original aspect ratio when resizing (not cut off excess pixels)')
parser.add_argument('--resample', dest='resample', default='lanczos',
choices=('nearest', 'bicubic', 'lanczos'),
help='which resampling filter to use for resizing (see Pillow documentation)')
args = parser.parse_args() args = parser.parse_args()
image = Image.open(args.img_file) image = Image.open(args.img_file)
mode = module_map[args.mode] mode = module_map[args.mode]
if args.resize and any(i != m for i, m in zip(image.size, (mode.WIDTH, mode.HEIGHT))): if not all(i >= m for i, m in zip(image.size, (mode.WIDTH, mode.HEIGHT))):
resample = getattr(Image, args.resample.upper())
if args.keep_aspect_ratio or args.keep_aspect:
orig_ratio = image.width / image.height
mode_ratio = mode.WIDTH / mode.HEIGHT
crop = orig_ratio != mode_ratio
else:
crop = False
if crop:
t = orig_ratio < mode_ratio
if args.keep_aspect:
t = orig_ratio > mode_ratio
if t:
w = mode.WIDTH
h = int(w / orig_ratio)
else:
h = mode.HEIGHT
w = int(orig_ratio * h)
else:
w = mode.WIDTH
h = mode.HEIGHT
image = image.resize((w, h), resample)
if args.keep_aspect:
newbg = Image.new('RGB', (mode.WIDTH, mode.HEIGHT))
if t:
newbg.paste(image, (0, int((mode.HEIGHT/2)-(h/2))))
else:
newbg.paste(image, (int((mode.WIDTH/2)-(w/2)), 0))
image = newbg.copy()
crop = False
if crop:
x = (image.width - mode.WIDTH) / 2
y = (image.height - mode.HEIGHT) / 2
image = image.crop((x, y, mode.WIDTH + x, mode.HEIGHT + y))
elif not all(i >= m for i, m in zip(image.size, (mode.WIDTH, mode.HEIGHT))):
print(('Image must be at least {m.WIDTH} x {m.HEIGHT} pixels ' print(('Image must be at least {m.WIDTH} x {m.HEIGHT} pixels '
'for mode {m.__name__}').format(m=mode), file=stderr) 'for mode {m.__name__}').format(m=mode), file=stderr)
raise SystemExit(1) raise SystemExit(1)
@ -90,10 +47,6 @@ def main():
def build_module_map(): def build_module_map():
try:
from collections import OrderedDict
module_map = OrderedDict()
except ImportError:
module_map = {} module_map = {}
for module in SSTV_MODULES: for module in SSTV_MODULES:
for mode in module.MODES: for mode in module.MODES:

View file

@ -1,40 +1,38 @@
#!/usr/bin/env python #!/usr/bin/env python
from __future__ import division from __future__ import division
from pysstv.sstv import byte_to_freq, FREQ_BLACK, FREQ_WHITE, FREQ_VIS_START from pysstv.sstv import byte_to_freq, FREQ_BLACK, FREQ_WHITE, FREQ_VIS_START
from pysstv.grayscale import GrayscaleSSTV from pysstv.grayscale import GrayscaleSSTV
from itertools import chain from itertools import chain
from enum import Enum
class Color(Enum): RED, GREEN, BLUE = range(3)
red = 0
green = 1
blue = 2
class ColorSSTV(GrayscaleSSTV): class ColorSSTV(GrayscaleSSTV):
def on_init(self): def on_init(self):
self.pixels = self.image.convert('RGB').load() self.pixels = self.image.load()
def encode_line(self, line): def encode_line(self, line):
msec_pixel = self.SCAN / self.WIDTH msec_pixel = self.SCAN / self.WIDTH
image = self.pixels image = self.pixels
for color in self.COLOR_SEQ: for index in self.COLOR_SEQ:
yield from self.before_channel(color) for item in self.before_channel(index):
for col in range(self.WIDTH): yield item
for col in xrange(self.WIDTH):
pixel = image[col, line] pixel = image[col, line]
freq_pixel = byte_to_freq(pixel[color.value]) freq_pixel = byte_to_freq(pixel[index])
yield freq_pixel, msec_pixel yield freq_pixel, msec_pixel
yield from self.after_channel(color) for item in self.after_channel(index):
yield item
def before_channel(self, color): def before_channel(self, index):
return [] return []
after_channel = before_channel after_channel = before_channel
class MartinM1(ColorSSTV): class MartinM1(ColorSSTV):
COLOR_SEQ = (Color.green, Color.blue, Color.red) COLOR_SEQ = (GREEN, BLUE, RED)
VIS_CODE = 0x2c VIS_CODE = 0x2c
WIDTH = 320 WIDTH = 320
HEIGHT = 256 HEIGHT = 256
@ -42,11 +40,11 @@ class MartinM1(ColorSSTV):
SCAN = 146.432 SCAN = 146.432
INTER_CH_GAP = 0.572 INTER_CH_GAP = 0.572
def before_channel(self, color): def before_channel(self, index):
if color is Color.green: if index == GREEN:
yield FREQ_BLACK, self.INTER_CH_GAP yield FREQ_BLACK, self.INTER_CH_GAP
def after_channel(self, color): def after_channel(self, index):
yield FREQ_BLACK, self.INTER_CH_GAP yield FREQ_BLACK, self.INTER_CH_GAP
@ -65,9 +63,10 @@ class ScottieS1(MartinM1):
def horizontal_sync(self): def horizontal_sync(self):
return [] return []
def before_channel(self, color): def before_channel(self, index):
if color is Color.red: if index == RED:
yield from MartinM1.horizontal_sync(self) for item in MartinM1.horizontal_sync(self):
yield item
yield FREQ_BLACK, self.INTER_CH_GAP yield FREQ_BLACK, self.INTER_CH_GAP
@ -77,12 +76,6 @@ class ScottieS2(ScottieS1):
WIDTH = 160 WIDTH = 160
class ScottieDX(ScottieS1):
VIS_CODE = 0x4c
# http://www.barberdsp.com/downloads/Dayton%20Paper.pdf
SCAN = 345.6000 - ScottieS1.INTER_CH_GAP
class Robot36(ColorSSTV): class Robot36(ColorSSTV):
VIS_CODE = 0x08 VIS_CODE = 0x08
WIDTH = 320 WIDTH = 320
@ -93,14 +86,14 @@ class Robot36(ColorSSTV):
C_SCAN = 44 C_SCAN = 44
PORCH = 1.5 PORCH = 1.5
SYNC_PORCH = 3 SYNC_PORCH = 3
INTER_CH_FREQS = [None, FREQ_WHITE, FREQ_BLACK] INTER_CH_FREQS = [None, FREQ_BLACK, FREQ_WHITE]
def on_init(self): def on_init(self):
self.yuv = self.image.convert('YCbCr').load() self.yuv = self.image.convert('YCbCr').load()
def encode_line(self, line): def encode_line(self, line):
pixels = [self.yuv[col, line] for col in range(self.WIDTH)] pixels = [self.yuv[col, line] for col in xrange(self.WIDTH)]
channel = 2 - (line % 2) channel = (line % 2) + 1
y_pixel_time = self.Y_SCAN / self.WIDTH y_pixel_time = self.Y_SCAN / self.WIDTH
uv_pixel_time = self.C_SCAN / self.WIDTH uv_pixel_time = self.C_SCAN / self.WIDTH
return chain( return chain(
@ -124,7 +117,7 @@ class PasokonP3(ColorSSTV):
Horizontal Sync - 25 time units of 1200 Hz. Horizontal Sync - 25 time units of 1200 Hz.
""" """
TIMEUNIT = 1000/4800. # ms TIMEUNIT = 1000/4800. # ms
COLOR_SEQ = (Color.red, Color.green, Color.blue) COLOR_SEQ = (RED, GREEN, BLUE)
VIS_CODE = 0x71 VIS_CODE = 0x71
WIDTH = 640 WIDTH = 640
HEIGHT = 480+16 HEIGHT = 480+16
@ -132,11 +125,11 @@ class PasokonP3(ColorSSTV):
SCAN = WIDTH * TIMEUNIT SCAN = WIDTH * TIMEUNIT
INTER_CH_GAP = 5 * TIMEUNIT INTER_CH_GAP = 5 * TIMEUNIT
def before_channel(self, color): def before_channel(self, index):
if color is Color.red: if index == self.COLOR_SEQ[0]:
yield FREQ_BLACK, self.INTER_CH_GAP yield FREQ_BLACK, self.INTER_CH_GAP
def after_channel(self, color): def after_channel(self, index):
yield FREQ_BLACK, self.INTER_CH_GAP yield FREQ_BLACK, self.INTER_CH_GAP
@ -155,101 +148,4 @@ class PasokonP7(PasokonP3):
INTER_CH_GAP = 5 * TIMEUNIT INTER_CH_GAP = 5 * TIMEUNIT
class PD90(ColorSSTV): MODES = (MartinM1, MartinM2, ScottieS1, ScottieS2, Robot36, PasokonP3, PasokonP5, PasokonP7)
VIS_CODE = 0x63
WIDTH = 320
HEIGHT = 256
SYNC = 20
PORCH = 2.08
PIXEL = 0.532
def gen_image_tuples(self):
yuv = self.image.convert('YCbCr').load()
for line in range(0, self.HEIGHT, 2):
yield from self.horizontal_sync()
yield FREQ_BLACK, self.PORCH
pixels0 = [yuv[col, line] for col in range(self.WIDTH)]
pixels1 = [yuv[col, line + 1] for col in range(self.WIDTH)]
for p in pixels0:
yield byte_to_freq(p[0]), self.PIXEL
for p0, p1 in zip(pixels0, pixels1):
yield byte_to_freq((p0[2] + p1[2]) / 2), self.PIXEL
for p0, p1 in zip(pixels0, pixels1):
yield byte_to_freq((p0[1] + p1[1]) / 2), self.PIXEL
for p in pixels1:
yield byte_to_freq(p[0]), self.PIXEL
class PD120(PD90):
VIS_CODE = 0x5f
WIDTH = 640
HEIGHT = 496
PIXEL = 0.19
class PD160(PD90):
VIS_CODE = 0x62
WIDTH = 512
HEIGHT = 400
PIXEL = 0.382
class PD180(PD120):
VIS_CODE = 0x60
PIXEL = 0.286
class PD240(PD120):
VIS_CODE = 0x61
PIXEL = 0.382
class PD290(PD240):
VIS_CODE = 0x5e
WIDTH = 800
HEIGHT = 616
PIXEL = 0.286
class WraaseSC2180(ColorSSTV):
VIS_CODE = 0x37
WIDTH = 320
HEIGHT = 256
COLOR_SEQ = (Color.red, Color.green, Color.blue)
SYNC = 5.5225
PORCH = 0.5
SCAN = 235.0
def before_channel(self, color):
if color is Color.red:
yield FREQ_BLACK, self.PORCH
else:
return []
def after_channel(self, color):
return []
class WraaseSC2120(WraaseSC2180):
VIS_CODE = 0x3f
# NB: there are "authoritative" sounding documents that will tell you SC-2
# 120 uses red and blue channels that have half the line width of the
# green channel. Having spent several hours trying to nut out why SC2-120
# images weren't decoding in anything else, I can say this is utter
# bunkum. The line width is the same for all three channels, just
# shorter.
SCAN = 156.0
def before_channel(self, color):
# Not sure why, but SC2-120 decoding seems to need an extra few sync
# pulses to decode in QSSTV and slowrx. Take the extra pulse out, and
# it slants something chronic and QSSTV loses sync regularly even on
# DX mode. Put it in, and both decode reliably. Go figure. SC2-180
# works just fine without this extra pulse at the start of each
# channel.
yield FREQ_BLACK, self.PORCH
yield from super().before_channel(color)
MODES = (MartinM1, MartinM2, ScottieS1, ScottieS2, ScottieDX, Robot36,
PasokonP3, PasokonP5, PasokonP7, PD90, PD120, PD160, PD180, PD240,
PD290, WraaseSC2120, WraaseSC2180)

View file

@ -1,167 +0,0 @@
#!/usr/bin/env python
class Image(object):
def __init__(self, content):
self.content = content
def load(self):
return self
def __getitem__(self, item):
if isinstance(item, tuple):
x, y = item
return Image('{0}[(ROW({1}) + COL({2})) * 3'.format(self.content, y, x))
elif isinstance(item, int):
return Image('{0} + RGB({1})]'.format(self.content, item))
else:
raise NotImplementedError()
def __rmul__(self, n):
return Image('({1} * {0})'.format(self.content, float(n)))
def __mul__(self, n):
return Image('({0} * {1})'.format(self.content, float(n)))
def __rtruediv__(self, n):
return Image('({1} / {0})'.format(self.content, n))
def __truediv__(self, n):
return Image('({0} / {1})'.format(self.content, n))
def __radd__(self, n):
return Image('({1} + {0})'.format(self.content, n))
def __add__(self, n):
return Image('({0} + {1})'.format(self.content, n))
def __str__(self):
return self.content
from pysstv.color import MartinM1, MartinM2, PasokonP3, PasokonP5, PasokonP7
import re
supported = [MartinM1, MartinM2, PasokonP3, PasokonP5, PasokonP7]
ROW_RE = re.compile(r'ROW\(\d+\)')
def main(sstv_class=None):
if sstv_class is None:
sstv_class = MartinM1
elif sstv_class not in supported:
raise NotImplementedError()
sstv = sstv_class(Image('img'), 44100, 16)
n = 0
yield '#define ROW(x) x'
yield '#define COL(x) x'
yield '#define RGB(x) x'
yield 'void convert(unsigned char *img, float *freqs, float *msecs, const int width) {\nint frq = 0;'
history = []
lut = {}
same_as = {}
for freq, msec in sstv.gen_freq_bits():
printed = 'freqs[frq] = {1}; msecs[frq++] = {2};'.format(n, freq, msec)
key = ROW_RE.sub('row', printed)
old = lut.get(key)
if old is not None:
same_as[n] = old
else:
lut[key] = n
history.append((printed, key))
n += 1
del lut
m_start, m_len = gen_matches(same_as, history, n)
for i in range(same_as[m_start]):
yield history[i][0]
yield 'for (int row = 0; row < width * {0}; row += width) {{'.format(sstv.HEIGHT)
for i in range(same_as[m_start], same_as[m_start] + m_len - 1):
yield ' ' + history[i][1]
yield '}'
yield '}}\n\n#define FREQ_COUNT {0}'.format(n)
def gen_matches(same_as, history, n):
cur_start = None
cur_len = None
cur_end = None
for i in range(n):
if cur_start is None:
tmp = same_as.get(i)
if tmp is not None:
cur_len = 1
cur_start = i
cur_end = tmp
else:
tmp = same_as.get(i)
if tmp is not None and history[tmp][1] == history[cur_end + 1][1] and cur_start > cur_end:
cur_len += 1
cur_end += 1
else:
if tmp is not None and history[tmp][1] == history[cur_end + 1][1]:
return cur_start, cur_len
tmp = same_as.get(i)
if tmp is None:
cur_start = None
else:
cur_len = 1
cur_start = i
cur_end = tmp
def test(img_file):
from subprocess import Popen, PIPE, check_output
from os import remove, path
from PIL import Image
from datetime import datetime
import struct
exe = './codegen-test-executable'
if not path.exists('stb_image.h'):
from urllib.request import urlretrieve
urlretrieve('https://raw.githubusercontent.com/nothings/stb/master/stb_image.h', 'stb_image.h')
try:
for sstv_class in supported:
print('Testing', sstv_class)
gcc = Popen(['gcc', '-xc', '-lm', '-o', exe, '-'], stdin=PIPE)
start = datetime.now()
with open(path.join(path.dirname(__file__), 'codeman.c')) as cm:
c_src = cm.read().replace('#include "codegen.c"', '\n'.join(main(sstv_class)))
gcc.communicate(c_src)
gen_elapsed = datetime.now() - start
print(' - gengcc took', gen_elapsed)
start = datetime.now()
gen = check_output([exe, img_file])
native_elapsed = datetime.now() - start
print(' - native took', native_elapsed)
img = Image.open(img_file)
sstv = sstv_class(img, 44100, 16)
start = datetime.now()
try:
for n, (freq, msec) in enumerate(sstv.gen_freq_bits()):
assert gen[n * 8:(n + 1) * 8] == struct.pack('ff', freq, msec)
except AssertionError:
mode_name = sstv_class.__name__
with open('/tmp/{0}-c.bin'.format(mode_name), 'wb') as f:
f.write(gen)
with open('/tmp/{0}-py.bin'.format(mode_name), 'wb') as f:
for n, (freq, msec) in enumerate(sstv.gen_freq_bits()):
f.write(struct.pack('ff', freq, msec))
with open('/tmp/{0}.c'.format(mode_name), 'w') as f:
f.write(c_src)
print((" ! Outputs are different, they've been saved to "
"/tmp/{0}-{{c,py}}.bin, along with the C source code "
"in /tmp/{0}.c").format(mode_name))
python_elapsed = datetime.now() - start
print(' - python took', python_elapsed)
print(' - speedup:', python_elapsed.total_seconds() / native_elapsed.total_seconds())
print('OK')
finally:
try:
remove(exe)
except OSError:
pass
if __name__ == '__main__':
from sys import argv
if len(argv) > 2 and argv[1] == 'test':
test(argv[2])
else:
print('\n'.join(main()))

View file

@ -1,25 +0,0 @@
#include <stdio.h>
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
#include "codegen.c"
int main(int argc, char **argv) {
int x, y, n;
unsigned char *img = stbi_load(argv[1], &x, &y, &n, 0);
if (n != 3) {
fprintf(stderr, "Only RGB images are supported\n");
return 1;
}
float freqs[FREQ_COUNT], msecs[FREQ_COUNT];
convert(img, freqs, msecs, x);
for (int i = 0; i < FREQ_COUNT; i++) {
fwrite(&(freqs[i]), 4, 1, stdout);
fwrite(&(msecs[i]), 4, 1, stdout);
}
return 0;
}

View file

@ -2,17 +2,17 @@
# -*- encoding: utf-8 -*- # -*- encoding: utf-8 -*-
# copy to ~/.gimp-2.8/plug-ins/ # copy to ~/.gimp-2.8/plug-ins/
# dependencies: GIMP 2.8, python-imaging-tk, python-pyaudio # dependencies: GIMP 2.8, python-imaging-tk
from gimpfu import register, main, pdb, PF_BOOL, PF_STRING, PF_RADIO, CLIP_TO_IMAGE from gimpfu import register, main, pdb, PF_BOOL, PF_STRING, PF_RADIO, CLIP_TO_IMAGE
from PIL import Image, ImageTk from PIL import Image, ImageTk
from tkinter import Tk, Canvas, Button, Checkbutton, IntVar, Frame, LEFT, NW from Tkinter import Tk, Canvas, Button, Checkbutton, IntVar, Frame, LEFT, NW
from pysstv import __main__ as pysstv_main from pysstv import __main__ as pysstv_main
from pysstv.examples.pyaudio_sstv import PyAudioSSTV from pysstv.examples.pyaudio_sstv import PyAudioSSTV
from pysstv.sstv import SSTV from pysstv.sstv import SSTV
from itertools import repeat from itertools import repeat
from threading import Thread from threading import Thread
from queue import Queue, Empty from Queue import Queue, Empty
from time import sleep from time import sleep
import gimp, os import gimp, os
@ -118,12 +118,12 @@ class ProgressCanvas(Canvas):
self.height_ratio = 1 self.height_ratio = 1
width, height = image.size width, height = image.size
pixels = image.load() pixels = image.load()
RED, GREEN, BLUE = list(range(3)) RED, GREEN, BLUE = range(3)
self.colors = ['#{0:02x}{1:02x}{2:02x}'.format( self.colors = ['#{0:02x}{1:02x}{2:02x}'.format(
contrast(sum(pixels[x, y][RED] for x in range(width)) / width), contrast(sum(pixels[x, y][RED] for x in xrange(width)) / width),
contrast(sum(pixels[x, y][GREEN] for x in range(width)) / width), contrast(sum(pixels[x, y][GREEN] for x in xrange(width)) / width),
contrast(sum(pixels[x, y][BLUE] for x in range(width)) / width)) contrast(sum(pixels[x, y][BLUE] for x in xrange(width)) / width))
for y in range(height)] for y in xrange(height)]
if height / float(width) > 1.5: if height / float(width) > 1.5:
width *= 2 width *= 2
elif width < 200: elif width < 200:
@ -152,6 +152,9 @@ def contrast(value):
else: else:
return 255 - value return 255 - value
def set_ptt_pin(port, pin, state):
getattr(port, 'set' + pin)(state)
def transmit_current_image(image, drawable, mode, vox, fskid, ptt_port, ptt_pin, ptt_state): def transmit_current_image(image, drawable, mode, vox, fskid, ptt_port, ptt_pin, ptt_state):
sstv = MODULE_MAP[mode] sstv = MODULE_MAP[mode]
if ptt_port is not None: if ptt_port is not None:
@ -237,7 +240,7 @@ register(
"*", "*",
[ [
(PF_RADIO, "mode", "SSTV mode", "MartinM1", (PF_RADIO, "mode", "SSTV mode", "MartinM1",
tuple((n, n) for n in sorted(MODULE_MAP.keys()))), tuple((n, n) for n in sorted(MODULE_MAP.iterkeys()))),
(PF_BOOL, "vox", "Include VOX tones", True), (PF_BOOL, "vox", "Include VOX tones", True),
(PF_STRING, "fskid", "FSK ID", ""), (PF_STRING, "fskid", "FSK ID", ""),
(PF_RADIO, "ptt_port", "PTT port", None, (PF_RADIO, "ptt_port", "PTT port", None,

View file

@ -5,7 +5,7 @@ Demonstrates playing the generated samples directly using PyAudio
Tested on PyAudio 0.2.7 http://people.csail.mit.edu/hubert/pyaudio/ Tested on PyAudio 0.2.7 http://people.csail.mit.edu/hubert/pyaudio/
""" """
from __future__ import division
from pysstv.sstv import SSTV from pysstv.sstv import SSTV
from time import sleep from time import sleep
from itertools import islice from itertools import islice

View file

@ -8,9 +8,9 @@ simply copying/linking images to the directory or suing an SSTV
receiver such as slowrx or QSSTV. receiver such as slowrx or QSSTV.
""" """
from __future__ import print_function
from pyinotify import WatchManager, Notifier, ProcessEvent, IN_CREATE from pyinotify import WatchManager, Notifier, ProcessEvent, IN_CREATE
from .pyaudio_sstv import PyAudioSSTV from pyaudio_sstv import PyAudioSSTV
from pysstv.color import MartinM1, MartinM2, ScottieS1, ScottieS2 from pysstv.color import MartinM1, MartinM2, ScottieS1, ScottieS2
from pysstv.grayscale import Robot8BW, Robot24BW from pysstv.grayscale import Robot8BW, Robot24BW
from PIL import Image from PIL import Image
@ -44,13 +44,13 @@ class EventHandler(ProcessEvent):
def get_module_for_filename(filename): def get_module_for_filename(filename):
basename, _ = path.splitext(path.basename(filename)) basename, _ = path.splitext(path.basename(filename))
for mode, module in MODE_MAP.items(): for mode, module in MODE_MAP.iteritems():
if mode in basename: if mode in basename:
return module return module
def get_module_for_image(image): def get_module_for_image(image):
size = image.size size = image.size
for mode in MODE_MAP.values(): for mode in MODE_MAP.itervalues():
if all(i >= m for i, m in zip(size, (mode.WIDTH, mode.HEIGHT))): if all(i >= m for i, m in zip(size, (mode.WIDTH, mode.HEIGHT))):
return mode return mode

View file

@ -9,14 +9,16 @@ class GrayscaleSSTV(SSTV):
self.pixels = self.image.convert('LA').load() self.pixels = self.image.convert('LA').load()
def gen_image_tuples(self): def gen_image_tuples(self):
for line in range(self.HEIGHT): for line in xrange(self.HEIGHT):
yield from self.horizontal_sync() for item in self.horizontal_sync():
yield from self.encode_line(line) yield item
for item in self.encode_line(line):
yield item
def encode_line(self, line): def encode_line(self, line):
msec_pixel = self.SCAN / self.WIDTH msec_pixel = self.SCAN / self.WIDTH
image = self.pixels image = self.pixels
for col in range(self.WIDTH): for col in xrange(self.WIDTH):
pixel = image[col, line] pixel = image[col, line]
freq_pixel = byte_to_freq(pixel[0]) freq_pixel = byte_to_freq(pixel[0])
yield freq_pixel, msec_pixel yield freq_pixel, msec_pixel

View file

@ -4,7 +4,7 @@ from __future__ import division, with_statement
from math import sin, pi from math import sin, pi
from random import random from random import random
from contextlib import closing from contextlib import closing
from itertools import cycle, chain from itertools import imap, izip, cycle, chain
from array import array from array import array
import wave import wave
@ -46,12 +46,12 @@ class SSTV(object):
data = array(fmt, self.gen_samples()) data = array(fmt, self.gen_samples())
if self.nchannels != 1: if self.nchannels != 1:
data = array(fmt, chain.from_iterable( data = array(fmt, chain.from_iterable(
zip(*([data] * self.nchannels)))) izip(*([data] * self.nchannels))))
with closing(wave.open(filename, 'wb')) as wav: with closing(wave.open(filename, 'wb')) as wav:
wav.setnchannels(self.nchannels) wav.setnchannels(self.nchannels)
wav.setsampwidth(self.bits // 8) wav.setsampwidth(self.bits // 8)
wav.setframerate(self.samples_per_sec) wav.setframerate(self.samples_per_sec)
wav.writeframes(data) wav.writeframes(data.tostring())
def gen_samples(self): def gen_samples(self):
"""generates discrete samples from gen_values() """generates discrete samples from gen_values()
@ -64,8 +64,8 @@ class SSTV(object):
amp = max_value // 2 amp = max_value // 2
lowest = -amp lowest = -amp
highest = amp - 1 highest = amp - 1
alias_cycle = cycle((alias * (random() - 0.5) for _ in range(1024))) alias_cycle = cycle((alias * (random() - 0.5) for _ in xrange(1024)))
for value, alias_item in zip(self.gen_values(), alias_cycle): for value, alias_item in izip(self.gen_values(), alias_cycle):
sample = int(value * amp + alias_item) sample = int(value * amp + alias_item)
yield (lowest if sample <= lowest else yield (lowest if sample <= lowest else
sample if sample <= highest else highest) sample if sample <= highest else highest)
@ -85,7 +85,7 @@ class SSTV(object):
samples += spms * msec samples += spms * msec
tx = int(samples) tx = int(samples)
freq_factor = freq * factor freq_factor = freq * factor
for sample in range(tx): for sample in xrange(tx):
yield sin(sample * freq_factor + offset) yield sin(sample * freq_factor + offset)
offset += (sample + 1) * freq_factor offset += (sample + 1) * freq_factor
samples -= tx samples -= tx
@ -104,7 +104,7 @@ class SSTV(object):
yield FREQ_SYNC, MSEC_VIS_BIT # start bit yield FREQ_SYNC, MSEC_VIS_BIT # start bit
vis = self.VIS_CODE vis = self.VIS_CODE
num_ones = 0 num_ones = 0
for _ in range(7): for _ in xrange(7):
bit = vis & 1 bit = vis & 1
vis >>= 1 vis >>= 1
num_ones += bit num_ones += bit
@ -113,9 +113,10 @@ class SSTV(object):
parity_freq = FREQ_VIS_BIT1 if num_ones % 2 == 1 else FREQ_VIS_BIT0 parity_freq = FREQ_VIS_BIT1 if num_ones % 2 == 1 else FREQ_VIS_BIT0
yield parity_freq, MSEC_VIS_BIT yield parity_freq, MSEC_VIS_BIT
yield FREQ_SYNC, MSEC_VIS_BIT # stop bit yield FREQ_SYNC, MSEC_VIS_BIT # stop bit
yield from self.gen_image_tuples() for freq_tuple in self.gen_image_tuples():
for fskid_byte in map(ord, self.fskid_payload): yield freq_tuple
for _ in range(6): for fskid_byte in imap(ord, self.fskid_payload):
for _ in xrange(6):
bit = fskid_byte & 1 bit = fskid_byte & 1
fskid_byte >>= 1 fskid_byte >>= 1
bit_freq = FREQ_FSKID_BIT1 if bit == 1 else FREQ_FSKID_BIT0 bit_freq = FREQ_FSKID_BIT1 if bit == 1 else FREQ_FSKID_BIT0

View file

@ -1,9 +1,4 @@
from os import path from os import path
import pickle
def get_asset_filename(filename): def get_asset_filename(filename):
return path.join(path.dirname(__file__), 'assets', filename) return path.join(path.dirname(__file__), 'assets', filename)
def load_pickled_asset(filename):
with open(get_asset_filename(filename + '.p'), 'rb') as f:
return pickle.load(f)

View file

@ -1,10 +1,11 @@
import unittest import unittest
from itertools import islice from itertools import islice
import pickle
from PIL import Image from PIL import Image
from pysstv import color from pysstv import color
from pysstv.tests.common import get_asset_filename, load_pickled_asset from pysstv.tests.common import get_asset_filename
class TestMartinM1(unittest.TestCase): class TestMartinM1(unittest.TestCase):
@ -16,12 +17,12 @@ class TestMartinM1(unittest.TestCase):
self.lena = color.MartinM1(lena, 48000, 16) self.lena = color.MartinM1(lena, 48000, 16)
def test_gen_freq_bits(self): def test_gen_freq_bits(self):
expected = load_pickled_asset("MartinM1_freq_bits") expected = pickle.load(open(get_asset_filename("MartinM1_freq_bits.p")))
actual = list(islice(self.s.gen_freq_bits(), 0, 1000)) actual = list(islice(self.s.gen_freq_bits(), 0, 1000))
self.assertEqual(expected, actual) self.assertEqual(expected, actual)
def test_gen_freq_bits_lena(self): def test_gen_freq_bits_lena(self):
expected = load_pickled_asset("MartinM1_freq_bits_lena") expected = pickle.load(open(get_asset_filename("MartinM1_freq_bits_lena.p")))
actual = list(islice(self.lena.gen_freq_bits(), 0, 1000)) actual = list(islice(self.lena.gen_freq_bits(), 0, 1000))
self.assertEqual(expected, actual) self.assertEqual(expected, actual)
@ -39,6 +40,7 @@ class TestMartinM1(unittest.TestCase):
self.maxDiff = None self.maxDiff = None
line_numbers = [1, 10, 100] line_numbers = [1, 10, 100]
for line in line_numbers: for line in line_numbers:
expected = load_pickled_asset("MartinM1_encode_line_lena{0}".format(line)) file = open(get_asset_filename("MartinM1_encode_line_lena%d.p" % line))
expected = pickle.load(file)
actual = list(self.lena.encode_line(line)) actual = list(self.lena.encode_line(line))
self.assertEqual(expected, actual) self.assertEqual(expected, actual)

View file

@ -1,13 +1,14 @@
import unittest import unittest
from io import BytesIO from itertools import islice, izip
from itertools import islice import pickle
import mock import mock
from mock import MagicMock from mock import MagicMock
from StringIO import StringIO
import hashlib import hashlib
from pysstv import sstv from pysstv import sstv
from pysstv.sstv import SSTV from pysstv.sstv import SSTV
from pysstv.tests.common import load_pickled_asset from common import get_asset_filename
class TestSSTV(unittest.TestCase): class TestSSTV(unittest.TestCase):
@ -20,7 +21,7 @@ class TestSSTV(unittest.TestCase):
def test_horizontal_sync(self): def test_horizontal_sync(self):
horizontal_sync = self.s.horizontal_sync() horizontal_sync = self.s.horizontal_sync()
expected = (1200, self.s.SYNC) expected = (1200, self.s.SYNC)
actual = next(iter(horizontal_sync)) actual = horizontal_sync.next()
self.assertEqual(expected, actual) self.assertEqual(expected, actual)
def test_gen_freq_bits(self): def test_gen_freq_bits(self):
@ -44,8 +45,8 @@ class TestSSTV(unittest.TestCase):
# FIXME: Instead of using a test fixture, 'expected' should be synthesized? # FIXME: Instead of using a test fixture, 'expected' should be synthesized?
def test_gen_values(self): def test_gen_values(self):
gen_values = self.s.gen_values() gen_values = self.s.gen_values()
expected = load_pickled_asset("SSTV_gen_values") expected = pickle.load(open(get_asset_filename("SSTV_gen_values.p")))
for e, g in zip(expected, gen_values): for e, g in izip(expected, gen_values):
self.assertAlmostEqual(e, g, delta=0.000000001) self.assertAlmostEqual(e, g, delta=0.000000001)
def test_gen_samples(self): def test_gen_samples(self):
@ -56,20 +57,20 @@ class TestSSTV(unittest.TestCase):
# and having different results. # and having different results.
# https://en.wikipedia.org/wiki/Quantization_%28signal_processing%29 # https://en.wikipedia.org/wiki/Quantization_%28signal_processing%29
sstv.random = MagicMock(return_value=0.4) # xkcd:221 sstv.random = MagicMock(return_value=0.4) # xkcd:221
expected = load_pickled_asset("SSTV_gen_samples") expected = pickle.load(open(get_asset_filename("SSTV_gen_samples.p")))
actual = list(islice(gen_values, 0, 1000)) actual = list(islice(gen_values, 0, 1000))
for e, a in zip(expected, actual): for e, a in izip(expected, actual):
self.assertAlmostEqual(e, a, delta=1) self.assertAlmostEqual(e, a, delta=1)
def test_write_wav(self): def test_write_wav(self):
self.maxDiff = None self.maxDiff = None
bio = BytesIO() sio = StringIO()
bio.close = MagicMock() # ignore close() so we can .getvalue() sio.close = MagicMock() # ignore close() so we can .getvalue()
mock_open = MagicMock(return_value=bio) mock_open = MagicMock(return_value=sio)
with mock.patch('builtins.open', mock_open): with mock.patch('__builtin__.open', mock_open):
self.s.write_wav('unittest.wav') self.s.write_wav('unittest.wav')
expected = 'dd7eed880ab3360fb79ce09c469deee2' expected = 'dd7eed880ab3360fb79ce09c469deee2'
data = bio.getvalue() data = sio.getvalue()
actual = hashlib.md5(data).hexdigest() actual = hashlib.md5(data).hexdigest()
self.assertEqual(expected, actual) self.assertEqual(expected, actual)

View file

@ -1,3 +1,3 @@
Pillow==10.3.0 Pillow==2.2.1
mock==1.0.1 mock==1.0.1
nose==1.3.0 nose==1.3.0

View file

@ -4,19 +4,14 @@ from setuptools import setup
setup( setup(
name='PySSTV', name='PySSTV',
version='0.5.7', version='0.2.6',
description='Python classes for generating Slow-scan Television transmissions', description='Python classes for generating Slow-scan Television transmissions',
author=u'András Veres-Szentkirályi', author=u'András Veres-Szentkirályi',
author_email='vsza@vsza.hu', author_email='vsza@vsza.hu',
url='https://github.com/dnet/pySSTV', url='https://github.com/dnet/pySSTV',
packages=['pysstv', 'pysstv.tests', 'pysstv.examples'], packages=['pysstv', 'pysstv.tests', 'pysstv.examples'],
entry_points={ keywords='HAM SSTV slow-scan television Scottie Martin Robot',
'console_scripts': [ install_requires = ['PIL',],
'pysstv = pysstv.__main__:main',
],
},
keywords='HAM SSTV slow-scan television Scottie Martin Robot Pasokon',
install_requires = ['Pillow'],
license='MIT', license='MIT',
classifiers=[ classifiers=[
'Development Status :: 4 - Beta', 'Development Status :: 4 - Beta',
@ -26,5 +21,4 @@ setup(
'Operating System :: OS Independent', 'Operating System :: OS Independent',
], ],
long_description=open('README.md').read(), long_description=open('README.md').read(),
long_description_content_type="text/markdown",
) )