Seaborn

Plotly has a lot of wow factor or whatever, but Seaborn produces the most impressive visual media I’ve basically ever seen in an inherently digital format.

We will use it today to produce a work of art that makes me feel roughly the way I felt the first time I saw imaging of the tiling work at the مسجد شاه. I have deliberately stolen the following image: it is “owned” by a company based in a country which sanctions the artists and artisans that maintain this tiling. I instead credit بهاء الدين محمد بن حسين العاملي, the mosque’s designer.

image.png

The grasp of color by the artists and artisans that produced this work is stunning. To my mind the only things I’ve seen come close outside of the Islamic Golden Age are rigorous scientific endeavours into color theory by modern vision scientists. This is unsurprising - much of modern mathematics arises from scholars who would’ve contemporaries of these artists.

Using a variety of scientific and mathematical techniques, seaborn contains are four uniquely impressive “color maps”, known as “perceptually uniform color maps”. Seaborn additional provides a way to address five earlier efforts from matplotlib which I find impressive but less so. They are as follows:

from PIL import Image as im
import numpy as np
import seaborn as sns
import random

CMPS = ["rocket", "mako", "flare", "crest", "viridis", "plasma", "inferno", "magma", "cividis"]
CMPS = [sns.color_palette(cmap, as_cmap=True) for cmap in CMPS] # I found this on the Seaborn website
CMPS[1]
mako
mako colormap
under
bad
over
CMPS[3]
crest
crest colormap
under
bad
over

Mako “makes me feel” similarly to the way the photography of the Isfahan tileset make me feel. But how can I create a tiling?

I believe the intent of بهاء الدين محمد بن حسين العاملي and other artisans is beyond my ability to grasp, so I will not place firm intent behind placing my tiles. Rather, I will place and color them randomly, then tune the process to evoke a similar feeling.

I will create an image of a fixed size, break it into tiles, color tiles with “mako” and perhaps other maps, and see how they make me feel.

siz = 400
# A way to create a new array of size s
new = lambda s : [[0 for i in range(s)] for j in range(s)]
# A way to see an array as an image
see = lambda a : im.fromarray(np.array(a).astype(np.uint8))
see(new(siz))

It would be a simple matter to break this into rectangles, but there seems a much more complex geometry at play in Isfahan. I will instead: * Select some number n of random locations * Computer which random location each pixel is closest too * Color each tile randomly. That is two randoms for those following along at home. Let’s begin

# A way to get a random point given some size s
rpt = lambda s : (random.randint(0, s - 1), random.randint(0, s - 1))
# A way to measure distance between two points. We want integers here.
dst = lambda a, b : int(((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5)

We need to test this code, so we’ll pick a random point and color the entire grid based on proximity.

Test it a few times to make sure it changes.

siz = 400
pnt = rpt(siz)
img = [[dst((i,j),pnt) for i in range(siz)] for j in range(siz)]
see(img)

It is non-obvious how to apply a color map. Here is one example. We create a grid that increases in one direction, and rather than plotting in grayscale, we plot the cmap as a function over the pixel location.

mako = CMPS[1]
mako(100)
(0.22698081, 0.35544612, 0.60642528, 1.0)

Here’s an interesting thing - those numbers are not between 0 and 255. They are between 0 and 1. So we need to multiple by 255 to be able to see much of anything at all.

mako = lambda x : [255 * i for i in CMPS[1](x)]
mako(100)
[57.88010655, 90.6387606, 154.6384464, 255.0]

Well, really we should be able to do this for all cmaps, so, we make a double lambda - it takes a cmap function that return 0-1 scale colors, and returns a new function that returns 0-255 scale colors.

scl = lambda c : lambda x : [255 * i for i in CMPS[c](x)]
mako = scl(1)
mako(100)
[57.88010655, 90.6387606, 154.6384464, 255.0]
siz = 400
img = [[i for i in range(siz)] for j in range(siz)]
see(img)

siz = 400
img = [[mako(i) for i in range(siz)] for j in range(siz)]
see(img)

We can test distance.

siz = 400
pnt = rpt(siz)
img = [[mako(dst((i,j),pnt)) for i in range(siz)] for j in range(siz)]
see(img)

Let’s try adding a few points, and coloring randomly based on which is closer.

To do this, we introduce a new “thing” - a dictionary. Dictionaries relate “keys” to “values” - like words to definitions. Ours will relate random points to random colors. Not too bad, I hope.

The notation is:

d = { key_0 : val_0, key_1 : val_1 }

We can look up a value given a key:

print(d[key_0]) # prints 'val_0'

Keys can be pretty much anything except a list or another dictionary. Values can be anything at all.

We can look at only keys or only values via d.keys() and d.values().

siz = 400
rps = [rpt(siz) for i in range(10)]
rps = {i : mako(random.randint(0, 255)) for i in rps}
rps.keys(), rps.values()
(dict_keys([(304, 206), (72, 206), (344, 325), (218, 264), (94, 348), (315, 272), (228, 204), (283, 189), (112, 368), (367, 31)]),
 dict_values([[64.342263, 69.5368323, 137.91755325, 255.0], [67.84041675, 187.58910015, 173.1808581, 255.0], [55.4014326, 167.58291705, 171.8255025, 255.0], [53.71616055, 110.54076855000001, 160.0290852, 255.0], [53.6094303, 111.6754344, 160.26311145, 255.0], [64.7269917, 66.41437260000001, 133.33145475, 255.0], [127.40400269999999, 215.13171645, 175.39901895, 255.0], [144.83702159999999, 219.26912715, 179.69406555, 255.0], [18.89536485, 8.5637262, 14.87830905, 255.0], [55.31160885, 40.41372345, 80.82060525, 255.0]]))

We can loop over the dictionary keys the way we loop over anything else…

_ = [print(i) for i in rps]
(304, 206)
(72, 206)
(344, 325)
(218, 264)
(94, 348)
(315, 272)
(228, 204)
(283, 189)
(112, 368)
(367, 31)

However, using indices is quite different. The points are indices, and the keys are the values at the index. It’s quite odd to look at this, so I grab a valid key by looping over the keys and taking the first key, then plugging that into the dictionary.

ky0 = [i for i in rps][0]
rps[ky0]
# Or in a single line...
rps[[i for i in rps][0]]
[61.12383405, 80.9751225, 149.487936, 255.0]

Okay - so now we have colors associated with areas. How do we associate a pixel with an area? Well, for each pixel, we need to know the closest point. We just loop over points and find the closest.

I do this by looping over the points in the dictionary, calculating distances, and taking the minimum. But I use one trick: Besides the distance, I also save the color associated with the point. So whatever the minimum is, that’s the color I want, because it’s the color associated with the closest point (and therefore my tile)!

This checks random points. See how each finds a different color!

min([(dst(rpt(400),i), rps[i]) for i in rps])
(70, [54.12077925, 107.11620525000001, 159.3030747, 255.0])

We’ll now define a few new things - a lambda that returns a tiling given (a) a cmap, (b) a size and (c) a number of colors, and a lambda that returns a color given a tiling and a point.

til = lambda c, s, n : {rpt(s) : scl(c)(random.randint(0,255)) for i in range(n)}
til(1, 400, 10)
{(304, 380): [103.6181178, 209.07908745, 172.59984825, 255.0],
 (383, 1): [56.447590500000004, 42.003378149999996, 84.25564905, 255.0],
 (365, 8): [53.1669849, 117.3129897, 161.4556164, 255.0],
 (191, 307): [18.89536485, 8.5637262, 14.87830905, 255.0],
 (297, 326): [52.1962458, 36.5139498, 72.31764555, 255.0],
 (171, 231): [20.942772599999998, 10.221241500000001, 17.7756981, 255.0],
 (59, 165): [64.0022613, 57.9121932, 117.8709246, 255.0],
 (194, 158): [54.12077925, 107.11620525000001, 159.3030747, 255.0],
 (162, 173): [52.76633145, 122.91452369999999, 162.7222728, 255.0],
 (80, 32): [55.31160885, 40.41372345, 80.82060525, 255.0]}
col = lambda t, p : min([(dst(p,i), t[i]) for i in t])[1]
t = til(1, 400, 10)
col(t, (100,100)), col(t, (100,101)), col(t, (300,300))
([63.228252149999996, 55.29738495, 112.45724399999999, 255.0],
 [63.228252149999996, 55.29738495, 112.45724399999999, 255.0],
 [45.6050211, 29.7484887, 57.3639075, 255.0])

We put it all together to tile an image. We just need a tiling and a size. We could do without the size in fact, but it’s a bit scuffed.

siz = 400
art = lambda t, s : [[col(t, (i,j)) for i in range(s)] for j in range(s)]
t = til(1, siz, 10)
see(art(t, siz))

With 400x400 = 16000 pixels, I probably need more than 10 tiles. Let’s try 160. It took me 33 seconds, which is slow but hey, art amirite.

see(art(til(1, siz, 160), siz))

I don’t know why I made this so hard to write:

see(art(til(1, siz, 160), siz))

Let’s make an easier function to let you test faster.

By the way, how long it takes to see something is basically height time width times number of tiles, just so you know what will take a long time.

Try different ‘c’ for color changes, and change ‘s’ and ‘n’ just in order to see something you like without waiting forever!