├── .circleci
└── config.yml
├── LICENSE.txt
├── Makefile
├── README.md
├── complex_colormap
├── __about__.py
├── __init__.py
├── cplot.py
└── generation.py
├── examples
└── analog_filter.py
├── requirements.txt
└── setup.py
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | # Python CircleCI 2.0 configuration file
2 | #
3 | # Check https://circleci.com/docs/2.0/language-python/ for more details
4 | #
5 | version: 2
6 | jobs:
7 | build:
8 | docker:
9 | # specify the version you desire here
10 | # use `-browsers` prefix for selenium tests, e.g. `3.6.1-browsers`
11 | - image: circleci/python:3.6.1
12 |
13 | # Specify service dependencies here if necessary
14 | # CircleCI maintains a library of pre-built images
15 | # documented at https://circleci.com/docs/2.0/circleci-images/
16 | # - image: circleci/postgres:9.4
17 |
18 | working_directory: ~/repo
19 |
20 | steps:
21 | - checkout
22 |
23 | # Download and cache dependencies
24 | - restore_cache:
25 | keys:
26 | - v1-dependencies-{{ checksum "requirements.txt" }}
27 | # fallback to using the latest cache if no exact match is found
28 | - v1-dependencies-
29 |
30 | - run:
31 | name: install dependencies
32 | command: |
33 | python3 -m venv venv
34 | . venv/bin/activate
35 | pip install -r requirements.txt
36 |
37 | - save_cache:
38 | paths:
39 | - ./venv
40 | key: v1-dependencies-{{ checksum "requirements.txt" }}
41 |
42 | # run tests!
43 | - run:
44 | name: run tests
45 | command: |
46 | . venv/bin/activate
47 | python complex_colormap/cplot.py
48 |
49 | - store_artifacts:
50 | path: test-reports
51 | destination: test-reports
52 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2017 endolith@gmail.com
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 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | VERSION=$(shell python3 -c "import complex_colormap; print(complex_colormap.__version__)")
2 |
3 | default:
4 | @echo "\"make publish\"?"
5 |
6 | README.rst: README.md
7 | cat README.md | sed -e 's__{width="\2"}_g' -e 's_
]*>__g' -e 's_
__g' > /tmp/README.md 8 | pandoc /tmp/README.md -o README.rst 9 | python3 setup.py check -r -s || exit 1 10 | 11 | tag: 12 | # Make sure we're on the master branch 13 | @if [ "$(shell git rev-parse --abbrev-ref HEAD)" != "master" ]; then exit 1; fi 14 | @echo "Tagging v$(VERSION)..." 15 | git tag v$(VERSION) 16 | git push --tags 17 | 18 | upload: setup.py README.rst 19 | @if [ "$(shell git rev-parse --abbrev-ref HEAD)" != "master" ]; then exit 1; fi 20 | rm -f dist/* 21 | python3 setup.py bdist_wheel --universal 22 | gpg --detach-sign -a dist/* 23 | twine upload dist/* 24 | 25 | publish: tag upload 26 | 27 | clean: 28 | rm -f README.rst 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # complex_colormap 2 | 3 | Plot complex functions in perceptually-uniform color space 4 | 5 | This generates a bivariate color map that adjusts both lightness and hue, for 6 | plotting complex functions, the magnitude and phase of signals, etc. 7 | 8 | Magnitude is mapped to lightness and phase angle is mapped to hue in a 9 | perceptually-uniform color space (previously 10 | [LCh](https://en.wikipedia.org/wiki/CIELAB_color_space#Cylindrical_model), 11 | now 12 | [CIECAM02's JCh](https://en.wikipedia.org/wiki/CIECAM02#Appearance_correlates)). 13 | 14 | ## Usage 15 | 16 | Since [matplotlib doesn't handle 2D colormaps natively](https://github.com/matplotlib/matplotlib/issues/14168), it's currently implemented 17 | as a `cplot` function that adds to an `axes` object, which you can then apply 18 | further MPL features to: 19 | 20 | ```py 21 | ax_cplot = fig.add_subplot() 22 | cplot(splane_eval, re=(-r, r), im=(-r, r), axes=ax_cplot) 23 | ax_cplot.set_xlabel('$\sigma$') 24 | ax_cplot.axis('equal') 25 | … 26 | ``` 27 | 28 | See [the example script](/examples/analog_filter.py). 29 | 30 | ## Color mapping 31 | 32 | There are currently two ways to handle the chroma information: 33 | 34 | ### Constant chroma (`'const'`) 35 | 36 | For each lightness `J`, find the maximum chroma that can be represented in RGB 37 | for *any* hue, and then use that for every *other* hue. This produces images with 38 | perceptually accurate magnitude variation, but the colors are muted and more 39 | difficult to perceive. 40 | 41 | [](https://flic.kr/p/22vsD6N) 42 | 43 |  44 |  45 | 46 | ### Maximum chroma (`'max'`) 47 | 48 | For each lightness `J` and hue `h`, find the maximum chroma that can be 49 | represented in RGB. This produces vivid images, but the chroma variation 50 | produces misleading streaks as it makes sharp angles around the RGB edges. 51 | 52 | [](https://flic.kr/p/22vsD43) 53 | 54 |  55 |  56 | 57 | ## Example 58 | 59 | [analog_filter.py](/examples/analog_filter.py) uses a constant-chroma map to 60 | visualize the poles and zeros of an analog bandpass filter, 61 | with accompanying magnitude and phase plots along jω axis, and a log-dB plot of 62 | magnitude for comparison: 63 | 64 | [](https://flic.kr/p/22zXQmJ) 65 | 66 | ## Distribution 67 | 68 | To create a new release 69 | 70 | 1. bump the `__version__` number, 71 | 72 | 2. publish to PyPi and GitHub: 73 | 74 | ```shell 75 | make publish 76 | ``` 77 | 78 | ## License 79 | 80 | complex_colormap is published under the [MIT license](https://en.wikipedia.org/wiki/MIT_License). 81 | -------------------------------------------------------------------------------- /complex_colormap/__about__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | __version__ = '0.0.1' 4 | __author__ = 'Jonathan Bright' 5 | __author_email__ = 'endolith@gmail.com' 6 | __website__ = 'https://github.com/endolith/complex_colormap' 7 | __license__ = 'License :: OSI Approved :: MIT License' 8 | __status__ = 'Development Status :: 2 - Pre-Alpha' 9 | -------------------------------------------------------------------------------- /complex_colormap/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | from __future__ import print_function 4 | 5 | from . import cplot, generation 6 | from .__about__ import __version__, __website__ 7 | -------------------------------------------------------------------------------- /complex_colormap/cplot.py: -------------------------------------------------------------------------------- 1 | """ 2 | Created on Thu Mar 14 2013 3 | 4 | Use lookup tables to color pictures 5 | 6 | TODO: Allow the infinity squashing function to be customized 7 | TODO: Colorspacious doesn't quite reach white for J = 100? 8 | TODO: cplot(np.tan, re=(3, 3), im=(-3, 3)) division by zero 9 | TODO: Lookup table is 8 MiB! 10 | TODO: Make phase rotatable? 11 | """ 12 | import numbers 13 | import os 14 | 15 | import matplotlib.image 16 | import matplotlib.pyplot as plt 17 | import numpy as np 18 | from colorspacious import cspace_convert 19 | from matplotlib.colors import colorConverter 20 | from scipy.interpolate import RectBivariateSpline, interp1d 21 | 22 | new_space = "JCh" 23 | 24 | # 2D C vs (J, h) 25 | C_lut = np.load(os.path.join(os.path.dirname(__file__), 'C_lut.npy')) 26 | 27 | # TODO: -360 to +360 is overkill for -180 to +180, just need a little extra 28 | max_J_vals = np.linspace(0, 100, C_lut.shape[0], endpoint=True) 29 | max_h_vals = np.linspace(-360, 0, C_lut.shape[1], endpoint=False) 30 | max_h_vals = np.concatenate((max_h_vals, max_h_vals + 360)) 31 | max_interpolator = RectBivariateSpline(max_J_vals, max_h_vals, 32 | np.tile(C_lut, 2)) 33 | 34 | # 1D C vs J 35 | C_lut_1d = C_lut.min(1) 36 | 37 | const_J_vals = np.linspace(0, 100, len(C_lut_1d), endpoint=True) 38 | const_interpolator = interp1d(const_J_vals, C_lut_1d, kind='linear') 39 | 40 | 41 | def to_rgb(color): 42 | if isinstance(color, numbers.Number): 43 | return colorConverter.to_rgb(str(color)) 44 | else: 45 | return colorConverter.to_rgb(color) 46 | 47 | 48 | # TODO suppress RuntimeWarning: invalid value 49 | 50 | def const_chroma_colormap(z, nancolor='gray'): 51 | """ 52 | Map complex value to color, with constant chroma at each lightness 53 | 54 | Magnitude is represented by lightness and angle is represented by hue. 55 | The interval [0, ∞] is mapped to lightness [0, 100]. 56 | 57 | Parameters 58 | ---------- 59 | z : array_like 60 | Complex numbers to be mapped. 61 | nancolor 62 | Color used to represent NaNs. Can be any valid matplotlib color, 63 | such as ``'k'``, ``'deeppink'``, ``'0.5'`` [gray], 64 | ``(1.0, 0.5, 0.0)`` [orange], etc. Defaults to gray (which cannot 65 | appear otherwise). 66 | 67 | Returns 68 | ------- 69 | rgb : ndarray 70 | Array of colors, with values varying from 0 to 1. Shape is same as 71 | input, but with an extra dimension for R, G, and B. 72 | 73 | Examples 74 | -------- 75 | A point with infinite magnitude will map to white, and magnitude 0 will 76 | map to black. A point with magnitude 10 and phase of π/2 will map to a 77 | pale yellow. NaNs will map to gray by default: 78 | 79 | >>> const_chroma_colormap([[np.inf, 0, 10j, np.nan]]) 80 | array([[[ 1. , 1. , 1. ], 81 | [ 0. , 0. , 0. ], 82 | [ 0.824, 0.706, 0.314], 83 | [ 0.502, 0.502, 0.502]]]) 84 | """ 85 | # TODO: Infinity squashing curve should be customizable 86 | 87 | # TODO: Somewhere between 98.24 and 98.34, the min C drops close to 0, so 88 | # cut it off before 100? Instead of having multiple J values that map to 89 | # white. No such problem at black end. Probably varies with illuminant? 90 | 91 | # Map magnitude in [0, ∞] to J in [0, 100] 92 | J = (1.0 - (1 / (1.0 + np.abs(z)**0.3))) * 100 93 | 94 | # Map angle in [0, 2π) to hue h in [0, 360) 95 | h = np.angle(z, deg=True) 96 | 97 | C = const_interpolator(J) 98 | 99 | # So if z is (vertical, horizontal), then 100 | # imshow expects shape of (vertical, horizontal, 3) 101 | JCh = np.stack((J, C, h), axis=-1) 102 | 103 | rgb = cspace_convert(JCh, new_space, "sRGB1") 104 | 105 | # White for infinity (colorspacious doesn't quite reach it for J = 100) 106 | rgb[np.isinf(z)] = (1.0, 1.0, 1.0) 107 | 108 | # Color NaNs 109 | rgb[np.isnan(z)] = to_rgb(nancolor) 110 | 111 | return rgb.clip(0, 1) 112 | 113 | 114 | def max_chroma_colormap(z, nancolor='gray'): 115 | """ 116 | Map complex value to color, with maximum chroma at each lightness 117 | 118 | Magnitude is represented by lightness and angle is represented by hue. 119 | The interval [0, ∞] is mapped to lightness [0, 100]. 120 | 121 | Parameters 122 | ---------- 123 | z : array_like 124 | Complex numbers to be mapped. 125 | nancolor 126 | Color used to represent NaNs. Can be any valid matplotlib color, 127 | such as ``'k'``, ``'deeppink'``, ``'0.5'`` [gray], 128 | ``(1.0, 0.5, 0.0)`` [orange], etc. 129 | 130 | Returns 131 | ------- 132 | rgb : ndarray 133 | Array of colors, with values varying from 0 to 1. Shape is same as 134 | input, but with an extra dimension for R, G, and B. 135 | 136 | Examples 137 | -------- 138 | A point with infinite magnitude will map to white, and magnitude 0 will 139 | map to black. A point with magnitude 10 and phase of π/2 will map to a 140 | saturated yellow. NaNs will map to gray by default: 141 | 142 | >>> max_chroma_colormap([[np.inf, 0, 10j, np.nan]]) 143 | array([[[ 1. , 1. , 1. ], 144 | [ 0.051, 0. , 0.001], 145 | [ 0.863, 0.694, 0. ], 146 | [ 0.502, 0.502, 0.502]]]) 147 | """ 148 | # Map magnitude in [0, ∞] to J in [0, 100] 149 | J = (1.0 - (1 / (1.0 + np.abs(z)**0.3))) * 100 150 | 151 | # Map angle in [0, 2π) to hue h in [0, 360) 152 | h = np.angle(z, deg=True) 153 | 154 | # TODO: Don't interpolate NaNs and get warnings 155 | 156 | # 2D interpolation of C lookup table 157 | C = max_interpolator(J, h, grid=False) 158 | 159 | # So if z is (vertical, horizontal), then 160 | # imshow expects shape of (vertical, horizontal, 3) 161 | JCh = np.stack((J, C, h), axis=-1) 162 | 163 | # TODO: Don't convert NaNs and get warnings 164 | rgb = cspace_convert(JCh, new_space, "sRGB1") 165 | 166 | # White for infinity (colorspacious doesn't quite reach it for J = 100) 167 | rgb[np.isinf(z)] = (1.0, 1.0, 1.0) 168 | 169 | # Color NaNs 170 | rgb[np.isnan(z)] = to_rgb(nancolor) 171 | 172 | return rgb.clip(0, 1) 173 | 174 | 175 | def cplot(f, re=(-5, 5), im=(-5, 5), points=160000, color='const', file=None, 176 | dpi=None, axes=None): 177 | r""" 178 | Plot a complex function using lightness for magnitude and hue for phase 179 | 180 | Plots the given complex-valued function `f` over a rectangular part 181 | of the complex plane specified by the pairs of intervals `re` and `im`. 182 | 183 | Parameters 184 | ---------- 185 | f : callable 186 | Function to be evaluated 187 | re : sequence of float 188 | Range for real axis (horizontal) 189 | im : sequence of float 190 | Range for imaginary axis (vertical) 191 | points : int 192 | Total number of points in the image. (e.g. points=9 produces a 3x3 193 | image) 194 | color : {'max', 'const', callable} 195 | Which colormap to use. 196 | If ``'max'``, maximize chroma for each point, which produces vivid 197 | images with misleading lightness. 198 | If ``'const'``, then the chroma is held constant for a given lightness, 199 | producing images with accurate amplitude, but muted colors. 200 | A custom function can also be supplied. See Notes. 201 | file : string or None 202 | Filename to save the figure to. If None, figure is displayed on 203 | screen. 204 | dpi : [ None | scalar > 0 | 'figure'] 205 | The resolution in dots per inch for the saved file. If None it will 206 | default to the value savefig.dpi in the matplotlibrc file. If 207 | 'figure', it will set the dpi to be the value of the figure. 208 | axes : matplotlib.axes._subplots.AxesSubplot 209 | An existing axes object in which to place the plot. 210 | 211 | Returns 212 | ------- 213 | axes : matplotlib.axes._subplots.AxesSubplot 214 | Axes object of the plot 215 | 216 | Notes 217 | ----- 218 | By default, the complex argument (phase) is shown as color (hue) and 219 | the magnitude is show as lightness. You can also supply a custom color 220 | function (`color`). This function should take an ndarray of complex 221 | numbers of shape (n, m) as input and return an ndarray of RGB 3-tuples of 222 | shape (n, m, 3), containing floats in the range 0.0-1.0. 223 | 224 | Examples 225 | -------- 226 | Show the default color mapping using the identity function: 227 | 228 | >>> cplot(lambda z: z) 229 | >>> plt.title(r'$f(z) = e^z$') 230 | 231 | Show the max chroma color mapping: 232 | 233 | >>> cplot(lambda z: z, color='max') 234 | >>> plt.title(r'$f(z) = e^z$') 235 | 236 | Plot the sine function. Zeros are black dots, positive real values are 237 | red, and negative real values are green: 238 | 239 | >>> cplot(np.sin) 240 | >>> plt.title(r'$f(z) = \sin(z)$') 241 | 242 | Plot the tan function with a smaller range. Poles (infinity) are shown as 243 | white dots: 244 | 245 | >>> cplot(np.tan, re=(-4, 4), im=(-4, 4)) 246 | >>> plt.title(r'$f(z) = \tan(z)$') 247 | 248 | Plot a function with NaN values as gray: 249 | 250 | >>> func = lambda z: np.sqrt(16 - z.real**2 - z.imag**2) 251 | >>> cplot(func) 252 | >>> plt.title(r'$f(z) = \sqrt{16 - ℜ(z)^2 - ℑ(z)^2}$') 253 | 254 | or customize the NaN color by using a custom color function: 255 | 256 | >>> nancolor = 'xkcd:radioactive green' 257 | >>> color_func = lambda z: const_chroma_colormap(z, nancolor=nancolor) 258 | >>> cplot(func, color=color_func) 259 | >>> plt.title(r'$f(z) = \sqrt{16 - ℜ(z)^2 - ℑ(z)^2}$') 260 | """ 261 | # Modified from mpmath.cplot 262 | if color == 'const': 263 | color = const_chroma_colormap 264 | elif color == 'max': 265 | color = max_chroma_colormap 266 | 267 | if file: 268 | axes = None 269 | 270 | fig = None 271 | if not axes: 272 | fig = plt.figure() 273 | axes = fig.add_subplot(1, 1, 1) 274 | 275 | re_lo, re_hi = re 276 | im_lo, im_hi = im 277 | re_d = re_hi - re_lo 278 | im_d = im_hi - im_lo 279 | M = int(np.sqrt(points * re_d / im_d) + 1) # TODO: off by one! 280 | N = int(np.sqrt(points * im_d / re_d) + 1) 281 | x = np.linspace(re_lo, re_hi, M) 282 | y = np.linspace(im_lo, im_hi, N) 283 | 284 | # Note: we have to be careful to get the right rotation. 285 | # Test with these plots: 286 | # cplot(np.vectorize(lambda z: z if z.real < 0 else 0)) 287 | # cplot(np.vectorize(lambda z: z if z.imag < 0 else 0)) 288 | z = x[None, :] + 1j * y[:, None] 289 | w = color(f(z)) 290 | 291 | if type(axes) == matplotlib.image.AxesImage: 292 | axes.set_data(w) 293 | axes = axes.axes 294 | else: 295 | axes.imshow(w, extent=(re_lo, re_hi, im_lo, im_hi), origin='lower') 296 | axes.set_xlabel('$\operatorname{Re}(z)$') 297 | axes.set_ylabel('$\operatorname{Im}(z)$') 298 | if fig: 299 | if file: 300 | plt.savefig(file, dpi=dpi) 301 | else: 302 | plt.show() 303 | 304 | return axes 305 | 306 | 307 | if __name__ == '__main__': 308 | cplot(lambda z: z, color='max') 309 | plt.title('$f(z) = z$') 310 | 311 | cplot(lambda z: z, color='const') 312 | plt.title('$f(z) = z$') 313 | -------------------------------------------------------------------------------- /complex_colormap/generation.py: -------------------------------------------------------------------------------- 1 | """ 2 | Created on Thu Mar 14 2013 3 | 4 | Generate lookup tables for 2D lightness/hue colormaps 5 | """ 6 | import matplotlib.pyplot as plt 7 | import numpy as np 8 | from colorspacious import cspace_convert 9 | 10 | new_space = "JCh" 11 | 12 | # Convert the RGB corners to JCh to know the total possible range 13 | RGB_corners = ((0, 0, 1), 14 | (0, 1, 0), 15 | (0, 1, 1), 16 | (1, 0, 0), 17 | (1, 0, 1),) 18 | 19 | JCh_corners = cspace_convert(RGB_corners, "sRGB1", new_space) 20 | C_max = max(JCh_corners[:, 1]) 21 | 22 | 23 | def dist_to_wall(J, C, h): 24 | """ 25 | Distance from a point in JCh space to the nearest wall of the RGB cube 26 | 27 | Not a very meaningful number; just used for optimization. Points inside 28 | RGB cube are positive; points outside return 0. (This is not necessarily 29 | monotonically decreasing.) 30 | 31 | Parameters 32 | ---------- 33 | J : float 34 | Lightness from 0 (black) to 100 (white) 35 | C : float 36 | Chroma, varies from 0 to 111 within the default RGB cube 37 | h : float 38 | Hue angle, which is cyclic from 0 to 360 degrees 39 | 40 | Returns 41 | ------- 42 | dist : float 43 | Distance. RGB values vary from 0 to 1, so this varies from 0.5 to 1. 44 | 45 | Examples 46 | -------- 47 | A point at the center of JCh space will be roughly near the center of RGB 48 | space, and so should be a distance of around 0.5 from any wall. 49 | 50 | >>> J, C, h = 50, 0, 0 51 | >>> dist_to_wall(J, C, h) 52 | 0.42313755309145551 53 | 54 | White and black are at corners, so 0 distance from three walls 55 | 56 | >>> J, C, h = 100, 0, 0 57 | >>> dist_to_wall(J, C, h) 58 | 0 59 | >>> J, C, h = 0, 0, 0 60 | >>> dist_to_wall(J, C, h) 61 | 0 62 | 63 | """ 64 | if J == 0 or J == 100: 65 | return 0 66 | 67 | r, g, b = cspace_convert((J, C, h), new_space, "sRGB1") 68 | 69 | # RGB vary from 0 to 1 70 | dists = np.array((1-r, 1-g, 1-b, r, g, b)).clip(0, 1) 71 | 72 | # Colorspacious produces NaNs outside of the RGB cube. Convert to zeros 73 | dists = np.nan_to_num(dists) 74 | 75 | return min(dists) 76 | 77 | 78 | def find_wall(J, h): 79 | """ 80 | Finds maximum C value that can be represented in RGB for a given J and h 81 | 82 | Parameters 83 | ---------- 84 | J : float 85 | Lightness from 0 (black) to 100 (white) 86 | h : float 87 | Hue angle, which is cyclic from 0 to 360 degrees 88 | 89 | Returns 90 | ------- 91 | C : float 92 | Chroma, varies from 0 to 111 within the default RGB cube 93 | """ 94 | if J == 0 or J == 100: 95 | return 0 96 | 97 | tol = 1/1000 98 | low = 0.0 99 | high = C_max + 10 100 | assert high > low 101 | assert (high + tol) > high 102 | 103 | def f(C): 104 | return dist_to_wall(J, C, h) 105 | 106 | # Bisection method from http://stackoverflow.com/a/15961284/125507 107 | while (high - low) > tol: 108 | 109 | mid = (low + high) / 2 110 | if f(mid) == 0: 111 | high = mid 112 | else: 113 | low = mid 114 | return high 115 | 116 | 117 | if __name__ == '__main__': 118 | J_lutsize = 1024 119 | h_lutsize = 1024 120 | 121 | # Lightness includes 0 (black) to 100 (white) 122 | J_vals = np.linspace(0, 100, J_lutsize, endpoint=True) 123 | 124 | # Hue is cyclic, where 360 degrees = 0 degrees (red) 125 | h_vals = np.linspace(0, 360, h_lutsize, endpoint=False) 126 | 127 | def create_max_chroma_lut(): 128 | print('Generating max chroma colormap lookup table') 129 | 130 | J, h = np.meshgrid(J_vals, h_vals, copy=False, indexing='ij') 131 | C = C_lut 132 | JCh = np.stack((J, C, h), axis=-1) 133 | max_chroma_lut = cspace_convert(JCh, new_space, "sRGB1") 134 | 135 | # Check that we're using entire RGB range but not exceeding it 136 | assert -0.01 < max_chroma_lut.min() < 0.1 137 | assert 0.9 < max_chroma_lut.max() < 1.02 138 | 139 | return max_chroma_lut.clip(0, 1) 140 | 141 | def create_C_lut(): 142 | print('Generating max chroma lookup table') 143 | C_lut = np.ones((J_lutsize, h_lutsize)) 144 | 145 | for n, J in enumerate(J_vals): 146 | print('J =', J) 147 | for m, h in enumerate(h_vals): 148 | C = find_wall(J, h) 149 | C_lut[n, m] = C 150 | 151 | return C_lut 152 | 153 | def create_const_chroma_lut(): 154 | print('Generating constant chroma colormap lookup table') 155 | J, h = np.meshgrid(J_vals, h_vals, copy=False, indexing='ij') 156 | C = np.tile(C_lut.min(1), (h_lutsize, 1)).T 157 | JCh = np.stack((J, C, h), axis=-1) 158 | const_chroma_lut = cspace_convert(JCh, new_space, "sRGB1") 159 | 160 | # Check that we're using entire RGB range but not exceeding it 161 | assert -0.01 < const_chroma_lut.min() < 0.1 162 | assert 0.9 < const_chroma_lut.max() < 1.02 163 | 164 | return const_chroma_lut.clip(0, 1) 165 | 166 | try: 167 | C_lut = np.load('C_lut.npy') 168 | print('Loaded chroma lookup table ' 169 | 'of {} J by {} h'.format(C_lut.shape[0], C_lut.shape[1])) 170 | except FileNotFoundError: 171 | C_lut = create_C_lut() 172 | np.save('C_lut.npy', C_lut) 173 | 174 | f, (ax1, ax2) = plt.subplots(1, 2, sharey=True, num='Maximum C') 175 | ax1.plot(C_lut.min(1), J_vals, label='min') 176 | ax1.plot(C_lut.max(1), J_vals, label='max') 177 | ax1.margins(0) 178 | ax1.set_xlabel('C') 179 | ax1.set_ylabel('J') 180 | ax1.legend() 181 | 182 | ax2.imshow(C_lut, origin='lower', interpolation='bilinear', 183 | extent=(0, 360, 0, 100)) 184 | ax2.set_xlabel('h') 185 | ax2.axis('auto') 186 | plt.tight_layout() 187 | 188 | max_chroma_lut = create_max_chroma_lut() 189 | 190 | plt.figure('Max chroma') 191 | plt.imshow(max_chroma_lut.clip(0, 1), origin='lower', 192 | interpolation='bilinear', extent=(0, 360, 0, 100)) 193 | plt.xlabel('h') 194 | plt.ylabel('J') 195 | plt.axis('auto') 196 | plt.tight_layout() 197 | 198 | const_chroma_lut = create_const_chroma_lut() 199 | 200 | plt.figure('Constant chroma') 201 | plt.imshow(const_chroma_lut.clip(0, 1), origin='lower', 202 | interpolation='bilinear', extent=(0, 360, 0, 100)) 203 | plt.xlabel('h') 204 | plt.ylabel('J') 205 | plt.axis('auto') 206 | plt.tight_layout() 207 | -------------------------------------------------------------------------------- /examples/analog_filter.py: -------------------------------------------------------------------------------- 1 | """ 2 | Generate analog signal processing filter, showing: 3 | Poles and zeros using complex colormap 4 | Magnitude and phase frequency response plots on linear-linear axes 5 | Magnitude plot on more typical log-log plot 6 | """ 7 | 8 | import matplotlib.gridspec as gridspec 9 | import matplotlib.pyplot as plt 10 | import numpy as np 11 | from scipy.signal import butter, freqs_zpk 12 | 13 | from complex_colormap.cplot import cplot 14 | 15 | f_c = 100 # rad/s 16 | r = 200 # rad/s 17 | 18 | z, p, k = butter(3, f_c, btype='hp', analog=True, output='zpk') 19 | 20 | 21 | def splane_eval(s): 22 | """ 23 | `s` is a value on the analog S plane. The frequency response at 10 Hz 24 | would be s = 1j*2*pi*10, for instance. 25 | """ 26 | return freqs_zpk(z, p, k, -1j * s)[1] 27 | 28 | 29 | gs = gridspec.GridSpec(2, 3, width_ratios=[2.5, 1, 1], height_ratios=[2.5, 1]) 30 | 31 | fig = plt.figure(figsize=(9, 7)) 32 | 33 | ax_cplot = fig.add_subplot(gs[0, 0]) 34 | cplot(splane_eval, re=(-r, r), im=(-r, r), axes=ax_cplot) 35 | ax_cplot.set_xlabel('$\sigma$') 36 | ax_cplot.set_ylabel('$j \omega$') 37 | ax_cplot.axhline(0, color='white', alpha=0.15) 38 | ax_cplot.axvline(0.5, color='white', alpha=0.15) 39 | ax_cplot.set_title('S plane') 40 | ax_cplot.axis('equal') 41 | 42 | w, h = freqs_zpk(z, p, k, np.linspace(-r, r, 500)) 43 | 44 | ax_fr = fig.add_subplot(gs[0, 1], sharey=ax_cplot) 45 | ax_fr.plot(abs(h), w) 46 | ax_fr.invert_xaxis() 47 | ax_fr.tick_params(axis='y', left=False, right=True, 48 | labelleft=False,) 49 | ax_fr.set_xlabel('Magnitude') 50 | ax_fr.grid(True, which='both') 51 | 52 | ax_ph = fig.add_subplot(gs[0, 2], sharey=ax_cplot) 53 | ax_ph.plot(np.rad2deg(np.angle(h)), w) 54 | ax_ph.invert_xaxis() 55 | ax_ph.tick_params(axis='y', left=False, right=True, 56 | labelleft=False, labelright=True) 57 | ax_ph.set_xlabel('Phase [degrees]') 58 | ax_ph.set_ylabel('Frequency [rad/s]') 59 | ax_ph.yaxis.set_label_position("right") 60 | ax_ph.grid(True, which='both') 61 | ax_ph.margins(0.05, 0) 62 | 63 | w, h = freqs_zpk(z, p, k, np.linspace(f_c / 10, f_c * 10, 500)) 64 | 65 | ax_ll = fig.add_subplot(gs[1, 0:]) 66 | ax_ll.semilogx(w, 20 * np.log10(abs(h))) 67 | ax_ll.grid(True, which='both') 68 | ax_ll.margins(0, 0.05) 69 | ax_ll.set_xlabel('Frequency [rad/s]') 70 | ax_ll.set_ylabel('Magnitude [dB]') 71 | 72 | plt.tight_layout() 73 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | colorspacious 2 | matplotlib 3 | numpy 4 | scipy -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | import codecs 4 | import os 5 | 6 | from setuptools import find_packages, setup 7 | 8 | # https://packaging.python.org/single_source_version/ 9 | base_dir = os.path.abspath(os.path.dirname(__file__)) 10 | about = {} 11 | with open(os.path.join(base_dir, 'complex_colormap', '__about__.py'), 12 | 'rb') as f: 13 | exec(f.read(), about) 14 | 15 | 16 | def read(fname): 17 | try: 18 | content = codecs.open( 19 | os.path.join(os.path.dirname(__file__), fname), 20 | encoding='utf-8' 21 | ).read() 22 | except Exception: 23 | content = '' 24 | return content 25 | 26 | 27 | setup( 28 | name='complex_colormap', 29 | version=about['__version__'], 30 | author=about['__author__'], 31 | author_email=about['__author_email__'], 32 | packages=find_packages(), 33 | description='Color maps for complex-valued functions', 34 | long_description=read('README.rst'), 35 | url=about['__website__'], 36 | download_url='https://github.com/endolith/complex_colormap/releases', 37 | license=about['__license__'], 38 | platforms='any', 39 | install_requires=[ 40 | 'colorspacious', 41 | 'matplotlib', 42 | 'numpy', 43 | 'scipy', 44 | ], 45 | classifiers=[ 46 | about['__status__'], 47 | about['__license__'], 48 | 'Intended Audience :: Science/Research', 49 | 'Operating System :: OS Independent', 50 | 'Programming Language :: Python', 51 | 'Programming Language :: Python :: 2', 52 | 'Programming Language :: Python :: 3', 53 | 'Topic :: Scientific/Engineering', 54 | 'Topic :: Scientific/Engineering :: Mathematics', 55 | 'Topic :: Scientific/Engineering :: Physics', 56 | ] 57 | ) 58 | --------------------------------------------------------------------------------