diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml new file mode 100644 index 0000000..14a4e65 --- /dev/null +++ b/.github/workflows/python-package.yml @@ -0,0 +1,40 @@ +# 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7a3c3e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +stb_image.h diff --git a/.travis.yml b/.travis.yml index 6d09563..6d6c1d1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,9 @@ language: python python: - - "2.7" + - "3.6" + - "3.7" + - "3.8" + - "3.9" sudo: false install: "pip install -r requirements.txt" script: nosetests diff --git a/README.md b/README.md index 7faea81..fd21db5 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ 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 (PNG, JPEG, GIF, and many others). These WAV files then can be played by any audio player connected to a shortwave radio for example. @@ -16,8 +14,10 @@ Command line usage $ python -m pysstv -h usage: __main__.py [-h] - [--mode {MartinM2,MartinM1,Robot24BW,ScottieS2,ScottieS1,Robot8BW,PasokonP3,PasokonP5,PasokonP7}] - [--rate RATE] [--bits BITS] + [--mode {MartinM1,MartinM2,ScottieS1,ScottieS2,ScottieDX,Robot36,PasokonP3,PasokonP5,PasokonP7,PD90,PD120,PD160,PD180,PD240,PD290,WraaseSC2120,WraaseSC2180,Robot8BW,Robot24BW}] + [--rate RATE] [--bits BITS] [--vox] [--fskid FSKID] + [--chan CHAN] [--resize] [--keep-aspect-ratio] + [--keep-aspect] [--resample {nearest,bicubic,lanczos}] image.png output.wav Converts an image to an SSTV modulated WAV file. @@ -26,12 +26,23 @@ Command line usage image.png input image file name output.wav output WAV file name - optional arguments: + options: -h, --help show this help message and exit - --mode {MartinM2,MartinM1,Robot24BW,ScottieS2,ScottieS1,Robot8BW,PasokonP3,PasokonP5,PasokonP7} + --mode {MartinM1,MartinM2,ScottieS1,ScottieS2,ScottieDX,Robot36,PasokonP3,PasokonP5,PasokonP7,PD90,PD120,PD160,PD180,PD240,PD290,WraaseSC2120,WraaseSC2180,Robot8BW,Robot24BW} image mode (default: Martin M1) --rate RATE sampling rate (default: 48000) --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 ---------------- @@ -71,5 +82,5 @@ Useful links Dependencies ------------ - - Python 2.7 (tested on 2.7.5) - - Python Imaging Library (Debian/Ubuntu package: `python-imaging`) + - Python 3.5 or later + - Python Imaging Library (Debian/Ubuntu package: `python3-pil`) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fed528d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" diff --git a/pysstv/__main__.py b/pysstv/__main__.py index 0468ae0..bc8f662 100644 --- a/pysstv/__main__.py +++ b/pysstv/__main__.py @@ -1,6 +1,6 @@ #!/usr/bin/env python -from __future__ import print_function +from __future__ import print_function, division from PIL import Image from argparse import ArgumentParser from sys import stderr @@ -30,10 +30,53 @@ def main(): help='add FSKID at the end') parser.add_argument('--chan', dest='chan', type=int, 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() image = Image.open(args.img_file) mode = module_map[args.mode] - if not all(i >= m for i, m in zip(image.size, (mode.WIDTH, mode.HEIGHT))): + if args.resize and any(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 ' 'for mode {m.__name__}').format(m=mode), file=stderr) raise SystemExit(1) @@ -47,7 +90,11 @@ def main(): def build_module_map(): - module_map = {} + try: + from collections import OrderedDict + module_map = OrderedDict() + except ImportError: + module_map = {} for module in SSTV_MODULES: for mode in module.MODES: module_map[mode.__name__] = mode diff --git a/pysstv/color.py b/pysstv/color.py index 683a85a..b52879c 100644 --- a/pysstv/color.py +++ b/pysstv/color.py @@ -1,38 +1,40 @@ #!/usr/bin/env python - from __future__ import division from pysstv.sstv import byte_to_freq, FREQ_BLACK, FREQ_WHITE, FREQ_VIS_START from pysstv.grayscale import GrayscaleSSTV from itertools import chain +from enum import Enum -RED, GREEN, BLUE = range(3) +class Color(Enum): + red = 0 + green = 1 + blue = 2 + class ColorSSTV(GrayscaleSSTV): def on_init(self): - self.pixels = self.image.load() + self.pixels = self.image.convert('RGB').load() def encode_line(self, line): msec_pixel = self.SCAN / self.WIDTH image = self.pixels - for index in self.COLOR_SEQ: - for item in self.before_channel(index): - yield item - for col in xrange(self.WIDTH): + for color in self.COLOR_SEQ: + yield from self.before_channel(color) + for col in range(self.WIDTH): pixel = image[col, line] - freq_pixel = byte_to_freq(pixel[index]) + freq_pixel = byte_to_freq(pixel[color.value]) yield freq_pixel, msec_pixel - for item in self.after_channel(index): - yield item + yield from self.after_channel(color) - def before_channel(self, index): + def before_channel(self, color): return [] after_channel = before_channel class MartinM1(ColorSSTV): - COLOR_SEQ = (GREEN, BLUE, RED) + COLOR_SEQ = (Color.green, Color.blue, Color.red) VIS_CODE = 0x2c WIDTH = 320 HEIGHT = 256 @@ -40,11 +42,11 @@ class MartinM1(ColorSSTV): SCAN = 146.432 INTER_CH_GAP = 0.572 - def before_channel(self, index): - if index == GREEN: + def before_channel(self, color): + if color is Color.green: yield FREQ_BLACK, self.INTER_CH_GAP - def after_channel(self, index): + def after_channel(self, color): yield FREQ_BLACK, self.INTER_CH_GAP @@ -63,10 +65,9 @@ class ScottieS1(MartinM1): def horizontal_sync(self): return [] - def before_channel(self, index): - if index == RED: - for item in MartinM1.horizontal_sync(self): - yield item + def before_channel(self, color): + if color is Color.red: + yield from MartinM1.horizontal_sync(self) yield FREQ_BLACK, self.INTER_CH_GAP @@ -76,6 +77,12 @@ class ScottieS2(ScottieS1): 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): VIS_CODE = 0x08 WIDTH = 320 @@ -86,14 +93,14 @@ class Robot36(ColorSSTV): C_SCAN = 44 PORCH = 1.5 SYNC_PORCH = 3 - INTER_CH_FREQS = [None, FREQ_BLACK, FREQ_WHITE] + INTER_CH_FREQS = [None, FREQ_WHITE, FREQ_BLACK] def on_init(self): self.yuv = self.image.convert('YCbCr').load() def encode_line(self, line): - pixels = [self.yuv[col, line] for col in xrange(self.WIDTH)] - channel = (line % 2) + 1 + pixels = [self.yuv[col, line] for col in range(self.WIDTH)] + channel = 2 - (line % 2) y_pixel_time = self.Y_SCAN / self.WIDTH uv_pixel_time = self.C_SCAN / self.WIDTH return chain( @@ -102,22 +109,22 @@ class Robot36(ColorSSTV): [(self.INTER_CH_FREQS[channel], self.INTER_CH_GAP), (FREQ_VIS_START, self.PORCH)], ((byte_to_freq(p[channel]), uv_pixel_time) for p in pixels)) - + class PasokonP3(ColorSSTV): """ - [ VIS code or horizontal sync here ] - Back porch - 5 time units of black (1500 Hz). - Red component - 640 pixels of 1 time unit each. - Gap - 5 time units of black. - Green component - 640 pixels of 1 time unit each. - Gap - 5 time units of black. - Blue component - 640 pixels of 1 time unit each. - Front porch - 5 time units of black. - Horizontal Sync - 25 time units of 1200 Hz. + [ VIS code or horizontal sync here ] + Back porch - 5 time units of black (1500 Hz). + Red component - 640 pixels of 1 time unit each. + Gap - 5 time units of black. + Green component - 640 pixels of 1 time unit each. + Gap - 5 time units of black. + Blue component - 640 pixels of 1 time unit each. + Front porch - 5 time units of black. + Horizontal Sync - 25 time units of 1200 Hz. """ TIMEUNIT = 1000/4800. # ms - COLOR_SEQ = (RED, GREEN, BLUE) + COLOR_SEQ = (Color.red, Color.green, Color.blue) VIS_CODE = 0x71 WIDTH = 640 HEIGHT = 480+16 @@ -125,11 +132,11 @@ class PasokonP3(ColorSSTV): SCAN = WIDTH * TIMEUNIT INTER_CH_GAP = 5 * TIMEUNIT - def before_channel(self, index): - if index == self.COLOR_SEQ[0]: + def before_channel(self, color): + if color is Color.red: yield FREQ_BLACK, self.INTER_CH_GAP - def after_channel(self, index): + def after_channel(self, color): yield FREQ_BLACK, self.INTER_CH_GAP @@ -148,4 +155,101 @@ class PasokonP7(PasokonP3): INTER_CH_GAP = 5 * TIMEUNIT -MODES = (MartinM1, MartinM2, ScottieS1, ScottieS2, Robot36, PasokonP3, PasokonP5, PasokonP7) +class PD90(ColorSSTV): + 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) diff --git a/pysstv/examples/codegen.py b/pysstv/examples/codegen.py new file mode 100644 index 0000000..777b026 --- /dev/null +++ b/pysstv/examples/codegen.py @@ -0,0 +1,167 @@ +#!/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())) diff --git a/pysstv/examples/codeman.c b/pysstv/examples/codeman.c new file mode 100644 index 0000000..a3460d5 --- /dev/null +++ b/pysstv/examples/codeman.c @@ -0,0 +1,25 @@ +#include + +#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; +} diff --git a/pysstv/examples/gimp-plugin.py b/pysstv/examples/gimp-plugin.py index d97683c..89d10dc 100755 --- a/pysstv/examples/gimp-plugin.py +++ b/pysstv/examples/gimp-plugin.py @@ -2,17 +2,17 @@ # -*- encoding: utf-8 -*- # copy to ~/.gimp-2.8/plug-ins/ -# dependencies: GIMP 2.8, python-imaging-tk +# dependencies: GIMP 2.8, python-imaging-tk, python-pyaudio from gimpfu import register, main, pdb, PF_BOOL, PF_STRING, PF_RADIO, CLIP_TO_IMAGE 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.examples.pyaudio_sstv import PyAudioSSTV from pysstv.sstv import SSTV from itertools import repeat from threading import Thread -from Queue import Queue, Empty +from queue import Queue, Empty from time import sleep import gimp, os @@ -118,12 +118,12 @@ class ProgressCanvas(Canvas): self.height_ratio = 1 width, height = image.size pixels = image.load() - RED, GREEN, BLUE = range(3) + RED, GREEN, BLUE = list(range(3)) self.colors = ['#{0:02x}{1:02x}{2:02x}'.format( - contrast(sum(pixels[x, y][RED] for x in xrange(width)) / width), - contrast(sum(pixels[x, y][GREEN] for x in xrange(width)) / width), - contrast(sum(pixels[x, y][BLUE] for x in xrange(width)) / width)) - for y in xrange(height)] + contrast(sum(pixels[x, y][RED] for x in range(width)) / width), + contrast(sum(pixels[x, y][GREEN] for x in range(width)) / width), + contrast(sum(pixels[x, y][BLUE] for x in range(width)) / width)) + for y in range(height)] if height / float(width) > 1.5: width *= 2 elif width < 200: @@ -152,9 +152,6 @@ def contrast(value): else: 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): sstv = MODULE_MAP[mode] if ptt_port is not None: @@ -240,7 +237,7 @@ register( "*", [ (PF_RADIO, "mode", "SSTV mode", "MartinM1", - tuple((n, n) for n in sorted(MODULE_MAP.iterkeys()))), + tuple((n, n) for n in sorted(MODULE_MAP.keys()))), (PF_BOOL, "vox", "Include VOX tones", True), (PF_STRING, "fskid", "FSK ID", ""), (PF_RADIO, "ptt_port", "PTT port", None, diff --git a/pysstv/examples/pyaudio_sstv.py b/pysstv/examples/pyaudio_sstv.py index ddbeed7..bf6f9f4 100644 --- a/pysstv/examples/pyaudio_sstv.py +++ b/pysstv/examples/pyaudio_sstv.py @@ -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/ """ -from __future__ import division + from pysstv.sstv import SSTV from time import sleep from itertools import islice diff --git a/pysstv/examples/repeater.py b/pysstv/examples/repeater.py index f3e29b3..80354e7 100644 --- a/pysstv/examples/repeater.py +++ b/pysstv/examples/repeater.py @@ -8,9 +8,9 @@ simply copying/linking images to the directory or suing an SSTV receiver such as slowrx or QSSTV. """ -from __future__ import print_function + 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.grayscale import Robot8BW, Robot24BW from PIL import Image @@ -44,13 +44,13 @@ class EventHandler(ProcessEvent): def get_module_for_filename(filename): basename, _ = path.splitext(path.basename(filename)) - for mode, module in MODE_MAP.iteritems(): + for mode, module in MODE_MAP.items(): if mode in basename: return module def get_module_for_image(image): size = image.size - for mode in MODE_MAP.itervalues(): + for mode in MODE_MAP.values(): if all(i >= m for i, m in zip(size, (mode.WIDTH, mode.HEIGHT))): return mode diff --git a/pysstv/grayscale.py b/pysstv/grayscale.py index 7b05b29..cc225cd 100644 --- a/pysstv/grayscale.py +++ b/pysstv/grayscale.py @@ -9,16 +9,14 @@ class GrayscaleSSTV(SSTV): self.pixels = self.image.convert('LA').load() def gen_image_tuples(self): - for line in xrange(self.HEIGHT): - for item in self.horizontal_sync(): - yield item - for item in self.encode_line(line): - yield item + for line in range(self.HEIGHT): + yield from self.horizontal_sync() + yield from self.encode_line(line) def encode_line(self, line): msec_pixel = self.SCAN / self.WIDTH image = self.pixels - for col in xrange(self.WIDTH): + for col in range(self.WIDTH): pixel = image[col, line] freq_pixel = byte_to_freq(pixel[0]) yield freq_pixel, msec_pixel diff --git a/pysstv/sstv.py b/pysstv/sstv.py index 29771db..0dab7c0 100644 --- a/pysstv/sstv.py +++ b/pysstv/sstv.py @@ -4,7 +4,7 @@ from __future__ import division, with_statement from math import sin, pi from random import random from contextlib import closing -from itertools import imap, izip, cycle, chain +from itertools import cycle, chain from array import array import wave @@ -46,12 +46,12 @@ class SSTV(object): data = array(fmt, self.gen_samples()) if self.nchannels != 1: data = array(fmt, chain.from_iterable( - izip(*([data] * self.nchannels)))) + zip(*([data] * self.nchannels)))) with closing(wave.open(filename, 'wb')) as wav: wav.setnchannels(self.nchannels) wav.setsampwidth(self.bits // 8) wav.setframerate(self.samples_per_sec) - wav.writeframes(data.tostring()) + wav.writeframes(data) def gen_samples(self): """generates discrete samples from gen_values() @@ -64,8 +64,8 @@ class SSTV(object): amp = max_value // 2 lowest = -amp highest = amp - 1 - alias_cycle = cycle((alias * (random() - 0.5) for _ in xrange(1024))) - for value, alias_item in izip(self.gen_values(), alias_cycle): + alias_cycle = cycle((alias * (random() - 0.5) for _ in range(1024))) + for value, alias_item in zip(self.gen_values(), alias_cycle): sample = int(value * amp + alias_item) yield (lowest if sample <= lowest else sample if sample <= highest else highest) @@ -85,7 +85,7 @@ class SSTV(object): samples += spms * msec tx = int(samples) freq_factor = freq * factor - for sample in xrange(tx): + for sample in range(tx): yield sin(sample * freq_factor + offset) offset += (sample + 1) * freq_factor samples -= tx @@ -104,7 +104,7 @@ class SSTV(object): yield FREQ_SYNC, MSEC_VIS_BIT # start bit vis = self.VIS_CODE num_ones = 0 - for _ in xrange(7): + for _ in range(7): bit = vis & 1 vis >>= 1 num_ones += bit @@ -113,10 +113,9 @@ class SSTV(object): parity_freq = FREQ_VIS_BIT1 if num_ones % 2 == 1 else FREQ_VIS_BIT0 yield parity_freq, MSEC_VIS_BIT yield FREQ_SYNC, MSEC_VIS_BIT # stop bit - for freq_tuple in self.gen_image_tuples(): - yield freq_tuple - for fskid_byte in imap(ord, self.fskid_payload): - for _ in xrange(6): + yield from self.gen_image_tuples() + for fskid_byte in map(ord, self.fskid_payload): + for _ in range(6): bit = fskid_byte & 1 fskid_byte >>= 1 bit_freq = FREQ_FSKID_BIT1 if bit == 1 else FREQ_FSKID_BIT0 diff --git a/pysstv/tests/common.py b/pysstv/tests/common.py index 005624f..eae997a 100644 --- a/pysstv/tests/common.py +++ b/pysstv/tests/common.py @@ -1,4 +1,9 @@ from os import path +import pickle def get_asset_filename(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) diff --git a/pysstv/tests/test_color.py b/pysstv/tests/test_color.py index 4f50dc7..8d9e292 100644 --- a/pysstv/tests/test_color.py +++ b/pysstv/tests/test_color.py @@ -1,11 +1,10 @@ import unittest from itertools import islice -import pickle from PIL import Image from pysstv import color -from pysstv.tests.common import get_asset_filename +from pysstv.tests.common import get_asset_filename, load_pickled_asset class TestMartinM1(unittest.TestCase): @@ -17,12 +16,12 @@ class TestMartinM1(unittest.TestCase): self.lena = color.MartinM1(lena, 48000, 16) def test_gen_freq_bits(self): - expected = pickle.load(open(get_asset_filename("MartinM1_freq_bits.p"))) + expected = load_pickled_asset("MartinM1_freq_bits") actual = list(islice(self.s.gen_freq_bits(), 0, 1000)) self.assertEqual(expected, actual) def test_gen_freq_bits_lena(self): - expected = pickle.load(open(get_asset_filename("MartinM1_freq_bits_lena.p"))) + expected = load_pickled_asset("MartinM1_freq_bits_lena") actual = list(islice(self.lena.gen_freq_bits(), 0, 1000)) self.assertEqual(expected, actual) @@ -40,7 +39,6 @@ class TestMartinM1(unittest.TestCase): self.maxDiff = None line_numbers = [1, 10, 100] for line in line_numbers: - file = open(get_asset_filename("MartinM1_encode_line_lena%d.p" % line)) - expected = pickle.load(file) + expected = load_pickled_asset("MartinM1_encode_line_lena{0}".format(line)) actual = list(self.lena.encode_line(line)) self.assertEqual(expected, actual) diff --git a/pysstv/tests/test_sstv.py b/pysstv/tests/test_sstv.py index ffcd630..4f59612 100644 --- a/pysstv/tests/test_sstv.py +++ b/pysstv/tests/test_sstv.py @@ -1,14 +1,13 @@ import unittest -from itertools import islice, izip -import pickle +from io import BytesIO +from itertools import islice import mock from mock import MagicMock -from StringIO import StringIO import hashlib from pysstv import sstv from pysstv.sstv import SSTV -from common import get_asset_filename +from pysstv.tests.common import load_pickled_asset class TestSSTV(unittest.TestCase): @@ -21,7 +20,7 @@ class TestSSTV(unittest.TestCase): def test_horizontal_sync(self): horizontal_sync = self.s.horizontal_sync() expected = (1200, self.s.SYNC) - actual = horizontal_sync.next() + actual = next(iter(horizontal_sync)) self.assertEqual(expected, actual) def test_gen_freq_bits(self): @@ -45,8 +44,8 @@ class TestSSTV(unittest.TestCase): # FIXME: Instead of using a test fixture, 'expected' should be synthesized? def test_gen_values(self): gen_values = self.s.gen_values() - expected = pickle.load(open(get_asset_filename("SSTV_gen_values.p"))) - for e, g in izip(expected, gen_values): + expected = load_pickled_asset("SSTV_gen_values") + for e, g in zip(expected, gen_values): self.assertAlmostEqual(e, g, delta=0.000000001) def test_gen_samples(self): @@ -57,20 +56,20 @@ class TestSSTV(unittest.TestCase): # and having different results. # https://en.wikipedia.org/wiki/Quantization_%28signal_processing%29 sstv.random = MagicMock(return_value=0.4) # xkcd:221 - expected = pickle.load(open(get_asset_filename("SSTV_gen_samples.p"))) + expected = load_pickled_asset("SSTV_gen_samples") actual = list(islice(gen_values, 0, 1000)) - for e, a in izip(expected, actual): + for e, a in zip(expected, actual): self.assertAlmostEqual(e, a, delta=1) def test_write_wav(self): self.maxDiff = None - sio = StringIO() - sio.close = MagicMock() # ignore close() so we can .getvalue() - mock_open = MagicMock(return_value=sio) - with mock.patch('__builtin__.open', mock_open): + bio = BytesIO() + bio.close = MagicMock() # ignore close() so we can .getvalue() + mock_open = MagicMock(return_value=bio) + with mock.patch('builtins.open', mock_open): self.s.write_wav('unittest.wav') expected = 'dd7eed880ab3360fb79ce09c469deee2' - data = sio.getvalue() + data = bio.getvalue() actual = hashlib.md5(data).hexdigest() self.assertEqual(expected, actual) diff --git a/requirements.txt b/requirements.txt index 4ed5a08..b89d905 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -Pillow==2.2.1 +Pillow==10.3.0 mock==1.0.1 nose==1.3.0 diff --git a/setup.py b/setup.py index 4a2e8ed..72796a6 100644 --- a/setup.py +++ b/setup.py @@ -4,14 +4,19 @@ from setuptools import setup setup( name='PySSTV', - version='0.2.7', + version='0.5.7', description='Python classes for generating Slow-scan Television transmissions', author=u'András Veres-Szentkirályi', author_email='vsza@vsza.hu', url='https://github.com/dnet/pySSTV', packages=['pysstv', 'pysstv.tests', 'pysstv.examples'], - keywords='HAM SSTV slow-scan television Scottie Martin Robot', - install_requires = ['Pillow',], + entry_points={ + 'console_scripts': [ + 'pysstv = pysstv.__main__:main', + ], + }, + keywords='HAM SSTV slow-scan television Scottie Martin Robot Pasokon', + install_requires = ['Pillow'], license='MIT', classifiers=[ 'Development Status :: 4 - Beta', @@ -21,4 +26,5 @@ setup( 'Operating System :: OS Independent', ], long_description=open('README.md').read(), + long_description_content_type="text/markdown", )