├── .envrc ├── .gitignore ├── LICENSE.txt ├── README.md ├── default.nix ├── doc ├── rad1o.jpg └── smiley.jpg ├── examples ├── pesthoernchen.png └── smiley.png ├── flake.lock ├── flake.nix ├── setup.py ├── shell.nix └── spectrum_painter ├── __init__.py ├── img2iqstream.py ├── radios.py └── spectrum_painter.py /.envrc: -------------------------------------------------------------------------------- 1 | if type lorri &>/dev/null; then 2 | echo "direnv: using lorri from PATH ($(type -p lorri))" 3 | eval "$(lorri direnv)" 4 | else 5 | # fall back to using direnv's builtin nix support 6 | # to prevent bootstrapping problems. 7 | use flake 8 | fi 9 | 10 | # source an additional user-specific .envrc in ./.envrc-local 11 | if [ -e .envrc-local ]; then 12 | source .envrc-local 13 | fi 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | // Python things 2 | __pycache__ 3 | *.pyc 4 | 5 | // My devenv 6 | *.iq 7 | *.iqhackrf 8 | *.egg-info 9 | .direnv 10 | .idea 11 | .ipynb_checkpoints 12 | notebooks 13 | 14 | // Nix things 15 | result 16 | result-* 17 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Polygon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spectrum Painter 2 | 3 | A tool to converts images to IQ streams that look like this when viewed in a waterfall plot. 4 | 5 | ![Spectrum Example](doc/smiley.jpg) 6 | 7 | ## Setup 8 | 9 | ### Dependencies 10 | 11 | The following packages are required: 12 | 13 | * [click](http://click.pocoo.org) 14 | * [numpy](http://www.numpy.org/) 15 | * [imageio](https://github.com/imageio/imageio) 16 | 17 | ### Installation 18 | 19 | Installation is not required. You can run the program from the root directory of the repository using 20 | 21 | ``` 22 | python3 -m spectrum_painter.img2iqstream 23 | ``` 24 | 25 | If you want the program to be globally executable, you can install it with pip. I recommend a developer install. From the root of the repository, this is done (for the current user) by 26 | 27 | ``` 28 | pip install --user -e . 29 | ``` 30 | 31 | Afterwards, _spectrum\_painter_ is available from everywhere. 32 | 33 | ### Nix 34 | 35 | A Nix Flake is now available. If you have a Nix system with flakes enabled, you can just run 36 | 37 | ``` 38 | nix shell github:/polygon/spectrum_painter 39 | ``` 40 | 41 | to get a shell with _spectrum\_painter_ available. 42 | 43 | For Nix systems without flakes, _default.nix_ and _shell.nix_ are available as shims using flake-compat. 44 | 45 | ## Usage 46 | 47 | Here is the program help, also available using _spectrum\_painter --help_. 48 | 49 | ``` 50 | Usage: spectrum_painter [OPTIONS] [SRCS]... 51 | 52 | Options: 53 | -s, --samplerate INTEGER Samplerate of the radio 54 | -l, --linetime FLOAT Time for each line to show 55 | -o, --output FILENAME File to write to (default: stdout) 56 | --format [float|bladerf|hackrf] 57 | Output format of samples 58 | --help Show this message and exit. 59 | ``` 60 | 61 | * Samplerate is what you will configure in your radio later. About half of that bandwidth is actually used for the image. The edges are left free since I've seen some pretty ugly bandfilter effects sometimes. 62 | * Linetime is the time in seconds that each line of your images will display. Experiment a bit here, usually a good starting value is around 0.005 - 0.01. 63 | * Output is the file to write to. Per default this is stdout. 64 | * Format selects the output formatter. There is support for bladerf and hackrf radio formats as well as raw I/Q interleaved 32-bit float samples. 65 | * You can pass multiple images to the program which will all be converted and written to the output. 66 | 67 | The FFT adapts to the image size. However, I've not tried what happens for very wide or narrow images. Pictures with a horizontal resolution between about 512-2048 pixels seem to work fine, though. Only the first color channel of the image is used, so images should be black and white. 68 | 69 | ### HackRF Example 70 | 71 | Convert the smiley example for the HackRF. 72 | 73 | ``` 74 | spectrum_painter -s 1000000 -l 0.004 -o smiley.iqhackrf --format hackrf examples/smiley.png 75 | ``` 76 | 77 | Then transmit using _hackrf_transfer_. 78 | 79 | ``` 80 | hackrf_transfer -t smiley.iqhackrf -f 2450000000 -b 1750000 -s 1000000 -x 20 -a 1 81 | ``` 82 | 83 | *NOTE:* I've got some reports that the above does not work with some original HackRF boards. Increasing the samplerate and bandwidth to 8 MHz seems to help in that case. If you have a rad1obadge, the lines above should work. 84 | 85 | ### BladeRF Example 86 | 87 | Convert the smiley example for the BladeRF 88 | 89 | ``` 90 | spectrum_painter -s 1000000 -l 0.004 -o smiley.iqblade --format bladerf examples/smiley.png 91 | ``` 92 | 93 | The output can be used in bladeRF-cli with bin format. 94 | 95 | # Closing 96 | 97 | This was a fun project at this years [Chaos Communication Camp](https://events.ccc.de/camp/2015/wiki/Main_Page). It even works with a spectrum analyzer on the awesome rad1obadge ;). 98 | 99 | ![rad1o example](doc/rad1o.jpg) 100 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | (import ( 2 | fetchTarball { 3 | url = "https://github.com/edolstra/flake-compat/archive/99f1c2157fba4bfe6211a321fd0ee43199025dbf.tar.gz"; 4 | sha256 = "0x2jn3vrawwv9xp15674wjz9pixwjyj3j771izayl962zziivbx2"; } 5 | ) { 6 | src = ./.; 7 | }).defaultNix 8 | 9 | -------------------------------------------------------------------------------- /doc/rad1o.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polygon/spectrum_painter/b8aef4b69551220e6ea520d345953e3a3873eef7/doc/rad1o.jpg -------------------------------------------------------------------------------- /doc/smiley.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polygon/spectrum_painter/b8aef4b69551220e6ea520d345953e3a3873eef7/doc/smiley.jpg -------------------------------------------------------------------------------- /examples/pesthoernchen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polygon/spectrum_painter/b8aef4b69551220e6ea520d345953e3a3873eef7/examples/pesthoernchen.png -------------------------------------------------------------------------------- /examples/smiley.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polygon/spectrum_painter/b8aef4b69551220e6ea520d345953e3a3873eef7/examples/smiley.png -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "locked": { 5 | "lastModified": 1619345332, 6 | "narHash": "sha256-qHnQkEp1uklKTpx3MvKtY6xzgcqXDsz5nLilbbuL+3A=", 7 | "owner": "numtide", 8 | "repo": "flake-utils", 9 | "rev": "2ebf2558e5bf978c7fb8ea927dfaed8fefab2e28", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "numtide", 14 | "repo": "flake-utils", 15 | "type": "github" 16 | } 17 | }, 18 | "nixpkgs": { 19 | "locked": { 20 | "lastModified": 1620590969, 21 | "narHash": "sha256-5W/6cikIr+N9+NAiEdVNc+p2y5AAZKuB2MXx9721IoY=", 22 | "owner": "NixOS", 23 | "repo": "nixpkgs", 24 | "rev": "fe50cb0ee149ab86277ab16a9f0fdd480df43eb2", 25 | "type": "github" 26 | }, 27 | "original": { 28 | "owner": "NixOS", 29 | "repo": "nixpkgs", 30 | "type": "github" 31 | } 32 | }, 33 | "root": { 34 | "inputs": { 35 | "flake-utils": "flake-utils", 36 | "nixpkgs": "nixpkgs" 37 | } 38 | } 39 | }, 40 | "root": "root", 41 | "version": 7 42 | } 43 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:NixOS/nixpkgs"; 4 | flake-utils.url = "github:numtide/flake-utils"; 5 | }; 6 | 7 | outputs = { self, nixpkgs, flake-utils }: 8 | flake-utils.lib.eachDefaultSystem (system: 9 | let pkgs = nixpkgs.legacyPackages.${system}; 10 | in rec { 11 | 12 | python_env = pkgs.python3.withPackages (ps: with ps; [ 13 | click 14 | numpy 15 | imageio 16 | ]); 17 | 18 | packages.spectrum_painter = pkgs.python3.pkgs.buildPythonPackage rec { 19 | pname = "spectrum_painter"; 20 | version = "0.1.0"; 21 | 22 | src = ./.; 23 | doCheck = false; 24 | 25 | propagatedBuildInputs = [ 26 | python_env 27 | ]; 28 | }; 29 | 30 | devShell = pkgs.mkShell { buildInputs = [ python_env ]; }; 31 | defaultPackage = packages.spectrum_painter; 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='spectrum_painter', 5 | version='0.1', 6 | packages=['spectrum_painter'], 7 | install_requires=[ 8 | 'Click', 9 | 'numpy', 10 | 'imageio', 11 | ], 12 | entry_points=''' 13 | [console_scripts] 14 | spectrum_painter=spectrum_painter.img2iqstream:img2iqstream 15 | ''', 16 | ) 17 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | (import ( 2 | fetchTarball { 3 | url = "https://github.com/edolstra/flake-compat/archive/99f1c2157fba4bfe6211a321fd0ee43199025dbf.tar.gz"; 4 | sha256 = "0x2jn3vrawwv9xp15674wjz9pixwjyj3j771izayl962zziivbx2"; } 5 | ) { 6 | src = ./.; 7 | }).shellNix 8 | 9 | -------------------------------------------------------------------------------- /spectrum_painter/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polygon/spectrum_painter/b8aef4b69551220e6ea520d345953e3a3873eef7/spectrum_painter/__init__.py -------------------------------------------------------------------------------- /spectrum_painter/img2iqstream.py: -------------------------------------------------------------------------------- 1 | import click 2 | from spectrum_painter.radios import GenericFloat, Bladerf, Hackrf 3 | from spectrum_painter.spectrum_painter import SpectrumPainter 4 | 5 | FORMATTERS = {'float': GenericFloat, 'bladerf': Bladerf, 'hackrf': Hackrf} 6 | 7 | @click.command() 8 | @click.option('--samplerate', '-s', type=int, default=1000000, help='Samplerate of the radio') 9 | @click.option('--linetime', '-l', type=float, default=0.005, help='Time for each line to show') 10 | @click.option('--output', '-o', type=click.File('wb'), help='File to write to (default: stdout)', default='-') 11 | @click.option('--format', type=click.Choice(['float', 'bladerf', 'hackrf']), default='float', help='Output format of samples') 12 | @click.argument('srcs', nargs=-1, type=click.Path(exists=True)) 13 | def img2iqstream(samplerate, linetime, output, format, srcs): 14 | formatter = FORMATTERS[format]() 15 | painter = SpectrumPainter(Fs=samplerate, T_line=linetime) 16 | if not srcs: 17 | return 18 | 19 | for src in srcs: 20 | iq_samples = painter.convert_image(src) 21 | target_format = formatter.convert(iq_samples) 22 | output.write(target_format.tostring()) 23 | 24 | 25 | if __name__ == '__main__': 26 | img2iqstream() 27 | -------------------------------------------------------------------------------- /spectrum_painter/radios.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from warnings import warn 3 | from subprocess import Popen, PIPE, STDOUT 4 | from tempfile import mktemp 5 | import os 6 | 7 | 8 | class Radio(object): 9 | def __init__(self): 10 | self.frequency = 446000000 11 | self.bandwidth = 1000000 12 | self.samplerate = 1000000 13 | 14 | def _interleave(self, complex_iq): 15 | # Interleave I and Q 16 | intlv = np.zeros(2*complex_iq.size, dtype=np.float32) 17 | intlv[0::2] = np.real(complex_iq) 18 | intlv[1::2] = np.imag(complex_iq) 19 | return intlv 20 | 21 | def _clip(self, complex_iq, limit=1.0): 22 | # Clips amplitude to level 23 | clipped_samples = np.abs(complex_iq) > limit 24 | if np.any(clipped_samples): 25 | clipped = complex_iq 26 | clipped[clipped_samples] = complex_iq[clipped_samples] / np.abs(complex_iq[clipped_samples]) 27 | warn('Some samples were clipped') 28 | else: 29 | clipped = complex_iq 30 | return clipped 31 | 32 | def transmit(self, complex_iq): 33 | raise NotImplementedError('transmit not implemented for this radio') 34 | 35 | def convert(self, complex_iq): 36 | raise NotImplementedError('convert not implemented for this radio') 37 | 38 | 39 | class GenericFloat(Radio): 40 | """ 41 | Generic interleaved float32 output for custom conversions 42 | """ 43 | def convert(self, complex_iq): 44 | return self._interleave(complex_iq) 45 | 46 | 47 | class Bladerf(Radio): 48 | """ 49 | Creates BladeRf formatted samples 50 | """ 51 | def convert(self, complex_iq): 52 | intlv = self._interleave(complex_iq) 53 | clipped = self._clip(intlv, limit=1.0) 54 | converted = 2047. * clipped 55 | bladerf_out = converted.astype(np.int16) 56 | return bladerf_out 57 | 58 | 59 | class BladeOut(GenericFloat): 60 | """ 61 | To be used with the bladeout tool, available on GitHub 62 | """ 63 | def __init__(self): 64 | super(BladeOut, self).__init__() 65 | self.txvga1 = -15 66 | self.txvga2 = 20 67 | 68 | def transmit(self, complex_iq): 69 | intlv = self.convert(complex_iq) 70 | bladeout = Popen(['bladeout', '-f', str(self.frequency), '-r', str(self.samplerate), '-b', str(self.bandwidth), 71 | '-g', str(self.txvga1), '-G', str(self.txvga2)], stdin=PIPE, stdout=PIPE, stderr=PIPE) 72 | stdout = bladeout.communicate(input=intlv.tostring()) 73 | return stdout 74 | 75 | 76 | class Hackrf(Radio): 77 | def __init__(self): 78 | super(Hackrf, self).__init__() 79 | self.txvga = 0 80 | self.rxvga = 0 81 | self.rxlna = 0 82 | 83 | def convert(self, complex_iq): 84 | intlv = self._interleave(complex_iq) 85 | clipped = self._clip(intlv) 86 | converted = 127. * clipped 87 | hackrf_out = converted.astype(np.int8) 88 | return hackrf_out 89 | 90 | def transmit(self, complex_iq): 91 | hackrf_out = self.convert(complex_iq) 92 | pipe_file = mktemp() 93 | os.mkfifo(pipe_file) 94 | hackout = Popen(['hackrf_transfer', '-f', str(self.frequency), '-s', str(self.samplerate), '-b', str(self.bandwidth), 95 | '-x', str(self.txvga), '-t', pipe_file], stdin=PIPE, stdout=PIPE, stderr=PIPE) 96 | pipe = open(pipe_file, 'wb') 97 | pipe.write(hackrf_out) 98 | pipe.close() 99 | hackout.wait() 100 | sout = hackout.communicate() 101 | os.unlink(pipe_file) 102 | return sout -------------------------------------------------------------------------------- /spectrum_painter/spectrum_painter.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import imageio as img 3 | 4 | 5 | class SpectrumPainter(object): 6 | def __init__(self, Fs=1000000, T_line=0.005): 7 | self.NFFT = 4096 8 | self.Fs = Fs 9 | self.T_line = T_line 10 | 11 | @property 12 | def repetitions(self): 13 | return int(np.ceil(self.T_line * self.Fs / self.NFFT)) 14 | 15 | def convert_image(self, filename): 16 | pic = img.imread(filename) 17 | # Set FFT size to be double the image size so that the edge of the spectrum stays clear 18 | # preventing some bandfilter artifacts 19 | self.NFFT = 2*pic.shape[1] 20 | 21 | # Repeat image lines until each one comes often enough to reach the desired line time 22 | ffts = (np.flipud(np.repeat(pic[:, :, 0], self.repetitions, axis=0) / 16.)**2.) / 256. 23 | 24 | # Embed image in center bins of the FFT 25 | fftall = np.zeros((ffts.shape[0], self.NFFT)) 26 | startbin = int(self.NFFT/4) 27 | fftall[:, startbin:(startbin+pic.shape[1])] = ffts 28 | 29 | # Generate random phase vectors for the FFT bins, this is important to prevent high peaks in the output 30 | # The phases won't be visible in the spectrum 31 | phases = 2*np.pi*np.random.rand(*fftall.shape) 32 | rffts = fftall * np.exp(1j*phases) 33 | 34 | # Perform the FFT per image line, then concatenate them to form the final signal 35 | timedata = np.fft.ifft(np.fft.ifftshift(rffts, axes=1), axis=1) / np.sqrt(float(self.NFFT)) 36 | linear = timedata.flatten() 37 | linear = linear / np.max(np.abs(linear)) 38 | return linear 39 | --------------------------------------------------------------------------------