├── readme.md └── scopeplot.py /readme.md: -------------------------------------------------------------------------------- 1 | Oscilloscope-like plotting of signals, showing density of waveforms instead 2 | of just the peak amplitude silhouettes. 3 | 4 | Vertical cross section through this plot is a histogram of the waveform values 5 | for that chunk as brightness. 6 | 7 | Input signal must have been sampled at twice the highest frequency present in 8 | the signal, like audio. 9 | 10 | Signal is first FFT interpolated to get inter-sample information, then broken 11 | up into overlapping chunks, 1 for each pixel, then those are linear 12 | interpolated, with each line segment contributing to 1 or 2 pixels, depending 13 | on where it occurs in the chunk. 14 | 15 | To do 16 | ----- 17 | 18 | - Handle line segments that go outside the visible range 19 | - Handle circularity/end behavior as a parameter 20 | - Fix white dots at endpoints 21 | - Read files one chunk at a time, FFT resample each chunk to avoid memory errors 22 | - Show original samples as circular dots when zoomed in enough 23 | - Show RMS value (window parameter?) 24 | - Show (intersample) peak value 25 | - Color the waveform based on spectral centroid, spectral content, etc. 26 | - Use randomized resampling? Completely different, though. 27 | 28 | Related 29 | ------- 30 | 31 | - https://github.com/endolith/freesound-thumbnailer 32 | - http://dsp.stackexchange.com/q/184/29 33 | 34 | Examples 35 | -------- 36 | 37 | Guitar pluck: 38 | 39 | [![plot of guitar](https://farm1.staticflickr.com/306/19701397555_58444c1ee0_z.jpg)](https://flic.kr/p/w1WP7c) 40 | 41 | Violins: 42 | 43 | [![plot of violins](https://c1.staticflickr.com/5/4741/39004191465_3a6908f435_z.jpg)](https://flic.kr/p/22qEFeK) 44 | 45 | Sine wave: 46 | 47 | [![plot of sine](https://farm1.staticflickr.com/417/19201290270_a91a64774e_z.jpg)](https://flic.kr/p/vfKCCN) 48 | 49 | Noise: 50 | 51 | [![plot of noise](https://c1.staticflickr.com/1/395/19112954693_bc4a597098_z.jpg)](https://flic.kr/p/v7WTxB) 52 | 53 | Triangle wave chirp showing it can deal with low-frequency waves, too: 54 | 55 | [![plot of triangle chirp](https://live.staticflickr.com/427/19078876704_5380c50eda_z.jpg)](https://flic.kr/p/v4Wem5) 56 | -------------------------------------------------------------------------------- /scopeplot.py: -------------------------------------------------------------------------------- 1 | """ 2 | Created on Sun May 24 2015 3 | 4 | Density plot of waveforms 5 | """ 6 | 7 | from __future__ import division, print_function 8 | 9 | import numpy as np 10 | from numpy import asarray, linspace, arange, pad 11 | import matplotlib.pyplot as plt 12 | from scipy.signal import resample 13 | from scipy.fftpack import next_fast_len 14 | 15 | # minimum resampling factor 16 | RS = 2 17 | 18 | 19 | def _ceildiv(a, b): 20 | """Ceiling integer division""" 21 | return -(-a // b) 22 | 23 | 24 | def _get_weights(N): 25 | """ 26 | Sample weights for a chunk with N samples per chunk. Determined by the 27 | amount a 1-pixel wide line centered on segment n would overlap the current 28 | pixel. 29 | 30 | So _get_weights(3) returns `[2/6, 4/6, 6/6, 4/6, 2/6]`, which means the 31 | center segment will only affect this pixel, the first segment's width will 32 | overlap this pixel by 4/6, the last segment of the previous chunk will 33 | overlap this pixel by 2/6, etc. 34 | """ 35 | if abs(int(N)) != N or N == 0: 36 | raise ValueError("N must be a positive integer") 37 | 38 | den = 2*N 39 | 40 | if N % 2: # odd 41 | num = np.concatenate((arange(2, 2*N + 1, 2), arange(2*N - 2, 0, -2))) 42 | else: # even 43 | num = np.concatenate((arange(1, 2*N, 2), arange(2*N - 1, 0, -2))) 44 | 45 | return num/den 46 | 47 | 48 | def _ihist(a, bins, range_): 49 | """ 50 | interpolated histogram 51 | 52 | a is a sequence of samples, considered to be connected by lines, including 53 | overlap of neighboring pixels 54 | 55 | bins is number of bins to group them into vertically 56 | 57 | range_ is total range of output, which may be wider than the widest values 58 | in a 59 | """ 60 | a = asarray(a) 61 | 62 | if a.ndim > 1: 63 | raise AttributeError('a must be a series. it will not be flattened') 64 | 65 | if (not np.isscalar(bins)) or (int(bins) != bins) or bins < 1: 66 | raise ValueError("`bins` should be a positive integer.") 67 | 68 | if a.size < 2 or a.size % 4 in {0, 3}: 69 | raise ValueError('not a valid size with overlap: {}'.format(a.size)) 70 | 71 | mn, mx = [mi + 0.0 for mi in range_] # Make float 72 | 73 | if a.min() < mn or a.max() > mx: 74 | raise NotImplementedError("values outside of range_ are not yet " 75 | "supported {} {}".format(a.min(), a.max())) 76 | 77 | if (mn >= mx): 78 | raise AttributeError('max must be larger than ' 79 | 'min in range_ parameter.') 80 | 81 | bin_edges = linspace(mn, mx, bins+1, endpoint=True) 82 | bin_width = (mx - mn)/bins 83 | 84 | pairs = np.vstack((a[:-1], a[1:])).T 85 | lower = np.minimum(pairs[:, 0], pairs[:, 1]) 86 | upper = np.maximum(pairs[:, 0], pairs[:, 1]) 87 | 88 | bin_lower = np.searchsorted(bin_edges, lower) 89 | bin_upper = np.searchsorted(bin_edges, upper) 90 | 91 | h = 1/(upper - lower) 92 | 93 | out = np.zeros(bins) 94 | 95 | weights = _get_weights(len(a) // 2) 96 | 97 | for n in range(len(pairs)): 98 | w = weights[n] 99 | lo = bin_lower[n] 100 | hi = bin_upper[n] 101 | if lo == hi: 102 | # Avoid divide by 0 103 | if lower[n] == bin_edges[lo]: 104 | # straddles 2 bins 105 | try: 106 | out[lo-1] += w * 0.5 107 | out[lo] += w * 0.5 108 | except IndexError: 109 | raise NotImplementedError('Values on edge of range_') 110 | # TODO: Could handle this with more ifthens, 111 | # but should be a smarter way 112 | else: 113 | out[lo-1] += w 114 | else: 115 | out[lo-1] += w * h[n] * (bin_edges[lo] - lower[n]) 116 | out[lo:hi-1] += w * h[n] * bin_width 117 | out[hi-1] += w * h[n] * (upper[n] - bin_edges[hi-1]) 118 | 119 | return out 120 | 121 | 122 | def scopeplot(x, width=800, height=400, range_=None, cmap=None, plot=None): 123 | """ 124 | Plot a signal using brightness to indicate density. 125 | 126 | Parameters 127 | ---------- 128 | x : array_like, 1-D 129 | The signal to be plotted 130 | width, height : int, optional 131 | The width and height of the output image in pixels. Default is 132 | 800×400. 133 | range_ : float or 2-tuple of floats, optional 134 | The vertical range of the plot. If a tuple, it is (xmin, xmax). If 135 | a single number, the range is (-range, range). If None, it autoscales. 136 | cmap : str or matplotlib.colors.LinearSegmentedColormap, optional 137 | A matplotlib colormap for 138 | Grayscale by default. 139 | plot : bool or str or None, optional 140 | If plot is None, the X image array is returned. 141 | if plot is True, the image is plotted directly. 142 | If plot is a string, it represents a filename to save the image to 143 | using matplotlib's `imsave`. 144 | 145 | Returns 146 | ------- 147 | X : ndarray of shape (width, height) 148 | A 2D array of amplitude 0 to 1, representing the density of the signal 149 | at that point. 150 | 151 | """ 152 | 153 | if cmap is None: 154 | cmap = 'gray' 155 | 156 | x = asarray(x) 157 | 158 | N = len(x) 159 | 160 | # Add zeros to end to reduce circular Gibbs effects 161 | MIN_PAD = 5 # TODO: what should this be? Seems subjective. 162 | 163 | # Make input an optimal length for fast processing 164 | pad_amount = next_fast_len(N + MIN_PAD) - N 165 | 166 | x = pad(x, (0, pad_amount), 'constant') 167 | 168 | # Resample such that signal evenly divides into chunks of equal length 169 | new_size = int(round(_ceildiv(RS*N, width) * width / N * len(x))) 170 | print('new size: {}'.format(new_size)) 171 | 172 | x = resample(x, new_size) 173 | 174 | if not range_: 175 | range_ = 1.1 * np.amax(np.abs(x)) 176 | 177 | if np.size(range_) == 1: 178 | xmin, xmax = -range_, +range_ 179 | elif np.size(range_) == 2: 180 | xmin, xmax = range_ 181 | else: 182 | raise ValueError('range_ not understood') 183 | 184 | spp = _ceildiv(N * RS, width) # samples per pixel 185 | norm = 1/spp 186 | 187 | # Pad some zeros at beginning for overlap 188 | x = pad(x, (spp//2, 0), 'constant') 189 | 190 | X = np.empty((width, height)) 191 | 192 | if spp % 2: # N is odd 193 | chunksize = 2*spp # (even) 194 | else: # N is even 195 | chunksize = 2*spp + 1 # (odd) 196 | print('spp: {}, chunk size: {}'.format(spp, chunksize)) 197 | 198 | for n in range(0, width): 199 | chunk = x[n*spp:n*spp+chunksize] 200 | assert len(chunk) # don't send empties 201 | try: 202 | h = _ihist(chunk, bins=height, range_=(xmin, xmax)) 203 | except ValueError: 204 | print('argh', len(chunk)) 205 | else: 206 | X[n] = h * norm 207 | 208 | assert np.amax(X) <= 1.001, np.amax(X) 209 | 210 | X = X**(0.4) # TODO: SUBJECTIVE 211 | 212 | if isinstance(plot, str): 213 | plt.imsave(plot, X.T, cmap=cmap, origin='lower', format='png') 214 | elif plot: 215 | fig = plt.figure() 216 | ax = fig.add_subplot(1, 1, 1) 217 | ax.imshow(X.T, origin='lower', aspect='auto', cmap=cmap, 218 | extent=(0, len(x), xmin, xmax), interpolation='nearest') 219 | # norm=LogNorm(vmin=0.01, vmax=300)) 220 | else: 221 | return X 222 | 223 | 224 | ######### 225 | # TESTS # 226 | ######### 227 | 228 | 229 | def test_get_weights(): 230 | from numpy.testing import assert_raises, assert_allclose 231 | 232 | assert_raises(ValueError, _get_weights, -3) 233 | assert_raises(ValueError, _get_weights, 0) 234 | 235 | assert_allclose(_get_weights(1), [2/2]) 236 | assert_allclose(_get_weights(2), [1/4, 3/4, 3/4, 1/4]) 237 | assert_allclose(_get_weights(3), [2/6, 4/6, 6/6, 4/6, 2/6]) 238 | assert_allclose(_get_weights(4), [1/8, 3/8, 5/8, 7/8, 7/8, 5/8, 3/8, 1/8]) 239 | assert_allclose(_get_weights(5), [2/10, 4/10, 6/10, 8/10, 10/10, 240 | 8/10, 6/10, 4/10, 2/10]) 241 | 242 | assert_allclose(np.sum(_get_weights(101)), 101) 243 | assert_allclose(np.sum(_get_weights(10111)), 10111) 244 | 245 | 246 | def test_ihist(SLOW_TESTS=False): 247 | from numpy.testing import (assert_array_equal, assert_raises, 248 | assert_allclose) 249 | 250 | # Invalid bins= 251 | assert_raises(ValueError, _ihist, [], (10, 4), (0, 10)) 252 | assert_raises(ValueError, _ihist, [1, 2], (10, 4), (0, 10)) 253 | assert_raises(ValueError, _ihist, [], -5, (0, 10)) 254 | assert_raises(ValueError, _ihist, [1, 2], -5, (0, 10)) 255 | assert_raises(ValueError, _ihist, [], 5.7, (0, 10)) 256 | assert_raises(ValueError, _ihist, [1, 2], 5.7, (0, 10)) 257 | 258 | # Incorrect number of samples with overlap 259 | """ 260 | Because of overlap, valid number of samples fed to hist are 261 | 2, 5, 6, 9, 10, 13, 14, 17, 18, 21, 22, 25, 26, 29, 30, 33, ... 262 | """ 263 | assert_raises(ValueError, _ihist, [], 3, (0, 1)) 264 | assert_raises(ValueError, _ihist, [1], 3, (0, 1)) 265 | assert_raises(ValueError, _ihist, [0.5], 3, (0, 1)) 266 | assert_raises(ValueError, _ihist, [3, 2, 1], 5, (0, 5)) 267 | assert_raises(ValueError, _ihist, [0.5, 0.5, 0.5, 0.5], 3, (0, 1)) 268 | assert_raises(ValueError, _ihist, np.ones(31), 5, (0, 5)) 269 | 270 | # 1 sample per pixel = 1 segment per pixel 271 | assert_array_equal(_ihist([2, 3], 3, (0, 3)), [0, 0, 1]) 272 | 273 | assert_allclose(_ihist([3.8, 5.8], 7, (3.5, 7)), 274 | [0.1, 0.25, 0.25, 0.25, 0.15, 0, 0]) 275 | 276 | assert_allclose(_ihist([5.8, 3.8], 7, (3.5, 7)), 277 | [0.1, 0.25, 0.25, 0.25, 0.15, 0, 0]) 278 | 279 | assert_allclose(_ihist([0, 1], 5, (0, 1)), [1/5, 1/5, 1/5, 1/5, 1/5]) 280 | 281 | assert_array_equal(_ihist([5.5, 7], 10, (0, 10)), 282 | [0, 0, 0, 0, 0, 1/3, 2/3, 0, 0, 0]) 283 | 284 | assert_array_equal(_ihist([0, 1], 5, (0, 5)), [1, 0, 0, 0, 0]) 285 | assert_array_equal(_ihist([3, 2], 5, (0, 5)), [0, 0, 1, 0, 0]) 286 | assert_array_equal(_ihist([4, 5], 5, (0, 5)), [0, 0, 0, 0, 1]) 287 | assert_array_equal(_ihist([0.5, 2.5], 3, (0, 3)), [1/4, 1/2, 1/4]) 288 | assert_array_equal(_ihist([0.5, 1.5], 3, (0, 3)), [1/2, 1/2, 0]) 289 | 290 | # Single line appears in all one bin 291 | assert_array_equal(_ihist([0.5, 0.5], 3, (0, 1)), [0, 1, 0]) 292 | assert_array_equal(_ihist([0.9, 0.9], 5, (0, 1)), [0, 0, 0, 0, 1]) 293 | 294 | # Falls entirely on bin edge, so half in each neighboring bin 295 | assert_array_equal(_ihist([2, 2], 3, (0, 3)), [0, 0.5, 0.5]) 296 | 297 | # Multiple segments, same value 298 | # 2 samples per pixel -> 5 samples per hist 299 | assert_allclose(_ihist(0.5*np.ones(5), 3, (0, 1)), [0, 1/4+3/4+3/4+1/4, 0]) 300 | 301 | # 3 samples per pixel -> 6 samples per hist 302 | assert_allclose(_ihist(0.5*np.ones(6), 3, (0, 1)), 303 | [0, 1/3+2/3+1+2/3+1/3, 0]) 304 | 305 | # 14 samples per pixel -> 29 samples per hist 306 | assert_allclose(_ihist(0.5*np.ones(29), 3, (0, 1)), [0, 14, 0]) 307 | assert_allclose(_ihist(0.5*np.ones(29), 2, (0, 1)), [7, 7]) 308 | 309 | # 15 samples per pixel -> 30 samples per hist 310 | assert_allclose(_ihist(0.5*np.ones(30), 3, (0, 1)), [0, 15, 0]) 311 | assert_allclose(_ihist(0.5*np.ones(30), 2, (0, 1)), [7.5, 7.5]) 312 | 313 | # Multiple segments, linear 314 | # 2 samples per pixel 315 | assert_allclose(_ihist([5, 4, 3, 2, 1], 4, (1, 5)), 316 | [1/4, 3/4, 3/4, 1/4]) 317 | 318 | # TODO: WRITE MORE 319 | 320 | # Random data sums correctly 321 | np.random.seed(42) # deterministic tests 322 | if SLOW_TESTS: 323 | S = 100 324 | else: 325 | S = 1 326 | for N in np.random.exponential(100000, size=S).astype('int'): 327 | if SLOW_TESTS: 328 | print(N, end=', ') 329 | bins = np.random.random_integers(1, 3000, 1)[0] # numpy int32 330 | if N % 2: # N is odd 331 | chunksize = 2*N # (even) 332 | else: # N is even 333 | chunksize = 2*N + 1 # (odd) 334 | a = np.random.randn(chunksize) 335 | lower = np.amin(a) - 15*np.random.rand(1)[0] 336 | upper = np.amax(a) + 14*np.random.rand(1)[0] 337 | assert_allclose(N, sum(_ihist(a, bins, (lower, upper)))) 338 | 339 | 340 | if __name__ == '__main__': 341 | from numpy import sin 342 | 343 | t = linspace(0, 20, 48000) 344 | sig = sin(t**3)*sin(t) 345 | 346 | plt.figure() 347 | plt.margins(0) 348 | plt.ylim(-2, 2) 349 | plt.plot(t, sig) 350 | 351 | scopeplot(sig, range_=2, plot=True) 352 | --------------------------------------------------------------------------------