python graphics 11 February 2023
Graphic by author
If you are not interested in any of the code or math but only want to see pretty graphics - jump straight to the graphics section.
In the past months I've been working with a lot with vector graphics. Although I wouldn't call myself a graphic designer, I was mistaken for one a couple of times at tech events. I quite enjoy creative design work since it's a really refreshing mode change from writing code or looking at data. I've always enjoyed making things look aesthetically pleasing but in my work that was mainly limited around creating charts and data visualizations, arranging those into dashboards and laying it out in some app. Occasionally I'm creating diagrams, but that's mostly for technical purposes.
Most of the work with vector graphics I've done to date was in Inkscape which I fell most comfortable with since I've been using on and off for a few good years. Although I've tried more modern, web based tools like Canva or Figma but whenever I had something a bit more advanced I always went back to Inkscape. All tools have their strengths, but they all require some learning investment before becoming productive. Inkscape turned out to be a great investment for me, but I feel I could do much more with vector graphics if I could tap into my python skills.
For some time I wanted to learn how to create vector graphics with python. I've watched a few conference
talks on creative coding and the whole concept resonated with me instantly. I started exploring what's
possible around the stack I'm familiar with. A good place to start was learning a bit more about
SVG itself which stands for Scalable Vector Graphics, and it
is an XML-based markup language that describes 2D vector graphics. For our purposes we won't need to go
very deep and only explore a few elements
and attributes
and that'll be sufficient to start creating
graphics.
What I would like to do is to be able to create repeated patterns (or tilings) of simple geometric shapes and apply a few effects to make it a bit less boring. I am aware that that Inkscape has a tool to create tilings based on repeating patterns, but I couldn't fully do what I had in mind and I wanted to try creating graphics programmatically. There is also a practical aspect to this project since I would like to come up with a design to cover a few windows with in a restaurant to increase privacy and comfort for guests.
I'll start with a simple square lattice^{1} and insert simple shapes like squares on the lattice positions. The plan is to start modifying the shapes to create interesting effects. In the end I'll repeat the whole set of designs on a hexagonal to compare the results.
As you might've expected, there are python packages out there, that can help you create SVGs without having to write raw XML and worrying about matching tags and debugging typos. I'll use svgwrite since it works out of the box with JupyterLab. Alternatively you can try drawSvg, they both seem to have a very similar API.
I'm doing most of my coding here in a jupyter notebook it might be worth mentioning that notebooks can
display SVGs natively. They can display SVG content directly in a cell either from a file or
from SVG source with the handy %%SVG
cell magic
%%SVG
<svg height="210" width="500">
<polygon
points="200,10 300,180 100,180"
style="fill:darkcyan;stroke:tomato;stroke-width:2"
/>
</svg>
should give you the following in the output cell:
Alternatively you can also use the builtin display system of IPython:
from IPython.core.display import SVG
SVG("""<svg height="210" width="500">
<polygon
points="100,200 200,200 150.0,113.39745962155614"
style="fill:darkcyan;stroke:tomato;stroke-width:2"
/>
<polygon
points="200,200 300,200 250.0,113.39745962155614"
style="fill:darkcyan;stroke:tomato;stroke-width:2"
/>
</svg>""")
should in turn produce:
As you can see SVG is quite straightforward. The drawing in enclosed in the <svg>...</svg>
tags, and I'm using
the polygon
element to draw the shapes by specifying the coordinates of all vertices. The appearance is then
modifies with the style
attribute in a CSS-like syntax.
Turning to python and making graphics with svgwrite
requires creating python representation of the drawing and
adding shapes to it. I'm only going to use rect
, circle
and polygon
elements with a few standard attributes like: fill
, stroke
and opacity
. Here's an example that illustrates basic functionality:
import svgwrite
dwg = svgwrite.Drawing(filename="example.svg", debug=True, size=(400, 140))
dwg.add(dwg.rect(
insert=(50, 20), size=(100, 50), fill="darkcyan", stroke="none", opacity=0.7))
dwg.add(dwg.rect(
insert=(180, 20), size=(50, 100), fill="darkcyan", stroke="none", opacity=1.0))
dwg.add(dwg.circle(
center=(290, 60), r=40, fill="tomato", stroke="none"))
dwg.add(dwg.polygon(
points=[(350, 120), (400, 120), (375.0, 13.5)], fill="tomato", stroke="none",
opacity=0.8))
That's it! We now know enough about SVG to start producing more advanced designs programmatically.
Square lattice is the best place to start since it's the simplest. What I want, specifically is a regular
grid of points whose coordinates will be expressed as the SVG canvas coordinates^{2}. Once we have the coordinates
we can start putting shapes in those locations. To define a square lattice it is sufficient to define a single parameter
a
which is the lattice parameter^{3}. Defining the lattice and size of our graphics will allow us to determine how
many tiles we'll be able to fit on the graphic of a given size. Here's how it might look in code:
@dataclass
class SquareLattice:
"Square Lattice in 2D"
a: float
def coords(self, row: int, col: int, center: bool = True) -> npt.ArrayLike:
"Coordinates for a lattice position (row, col)"
if center:
return np.array([
self.a * (row + 0.5),
self.a * (col + 0.5)], dtype=float)
else:
return np.array([self.a * row, self.a * col], dtype=float)
def size(self, width: int, height: int) -> npt.ArrayLike:
"Size of the lattice in lattice postions"
s = math.ceil(width / self.a) + 1
return np.array([s, s], dtype=int)
SquareLatttice
takes a single parameter a
and with the coords
method will generate (x, y)
coordinates for each of the integer lattice positions (i, j)
. To see how it works let's generate a few coordinate pairs:
lat = SquareLattice(a=10)
print(f"coordiantes for position (0, 0): {lat.coords(0, 0)}")
print(f"coordiantes for position (0, 0): {lat.coords(1, 0)}")
print(f"coordiantes for position (0, 0): {lat.coords(0, 1)}")
print(f"coordiantes for position (0, 0): {lat.coords(2, 2)}")
coordiantes for position (0, 0): [5. 5.]
coordiantes for position (0, 0): [15. 5.]
coordiantes for position (0, 0): [ 5. 15.]
coordiantes for position (0, 0): [25. 25.]
By default, we get coordinates for the center of each cell. It will only be important later on, but it's good to notice.
Now that we have the lattice positions we can put some elements there. For a square lattice, squares are a good starting point. Here's how we can create the drawing and repeat squares on the predefined lattice:
def draw_squares(
lattice: Lattice,
side: int = 15,
width: int = 800,
height: int = 800,
fill: str = "darkcyan",
stroke: str = "none",
opacity: float = 1.0,
):
"Draw SVG squares on a lattice"
dwg = svgwrite.Drawing(debug=True, size=(width, height))
xsize, ysize = lattice.size(width, height)
for i in range(xsize):
for j in range(ysize):
dwg.add(
dwg.rect(
insert=lattice.coords(i, j),
size=(side, side),
fill=fill,
stroke=stroke,
opacity=opacity,
)
)
return dwg
Since we can independently control the lattice spacing and size of the squares let's see a few variants:
Nothing spectacular yet but we need this foundation to be able to quickly modify the designs.
There are many ways to modify the grids we've produced so far to make it more interesting. Obvious example is probably changing the color channel but before going there I will try changing the size of the squares. The effect that I think it will have to imitate brightness where smaller shapes will be less intense and bigger elements more intense. Alternatively I would try to modify opacity in a similar fashion, but I think encoding brightness with size will be a nicer aesthetic.
I want to simulate a fade effect with a square size gradient. I would like to try out a few different gradient functions, but I'll start with the linear gradient along the y axis, that we'll use to modify elements.
With the function below I'm going to be able to create an array with coefficients [0.0, 1.0]
to scale
the squares by. Since the gradient will be applied along the y axis the size of the gradient array
is the same as the number of lattice points in the y direction.
def linear_gradient(
size: int,
offset: float = 0.0,
geom: bool = False,
start: float = 0.01
):
c = math.floor(size * offset)
if geom:
return np.hstack((
np.geomspace(start, 1, size - c, endpoint=False),
np.ones(c)
))
else:
return np.hstack((
np.linspace(start, 1, size - c, endpoint=False),
np.ones(c)
))
To make it work we'll need a small modification to the drawing function to take gradient
argument
and scale the squares with it. Here's the updated function:
def draw_squares(
lattice: Lattice,
side: int = 15,
width: int = 800,
height: int = 800,
fill: str = "darkcyan",
stroke: str = "none",
opacity: float = 1.0,
gradient: npt.ArrayLike = None,
):
"Draw SVG squares on a lattice with gradient"
dwg = svgwrite.Drawing(debug=True, size=(width, height))
xsize, ysize = lattice.size(width, height)
for i in range(xsize):
for j in range(ysize):
size = side * gradient[j]
dwg.add(
dwg.rect(
insert=lattice.coords(i, j),
size=(size, size),
fill=fill,
stroke=stroke,
opacity=opacity,
)
)
return dwg
Putting it all together and generating the grid with the gradient gives the following result
If we draw squares that are a bit larger we'll notice a slight problem with the current approach since
the default rect
element is drawn with respect to top left corner which results in an alignment to the left.
Although it might be desirable is some cases I would rather have all the squares centered with respect
to the lattice positions. To make that happen I created a custom Square
class that I'll use with
the polygon
element instead of rect
. Here's the Square
class and updated draw_squares
function
@dataclass
class Square:
"Square"
side: int
def coords(self):
"Vertex coordinates of a square with repect to its center (0, 0)"
return np.array([
(-self.side / 2, -self.side / 2),
(-self.side / 2, self.side / 2),
(self.side / 2, self.side / 2),
(self.side / 2, -self.side / 2),
])
def draw_squares(
lattice: Lattice,
side: int = 15,
width: int = 800,
height: int = 800,
fill: str = "darkcyan",
stroke: str = "none",
opacity: float = 1.0,
gradient: npt.ArrayLike = None,
):
"Draw SVG squares on a lattice"
dwg = svgwrite.Drawing(debug=True, size=(width, height))
xsize, ysize = lattice.size(width, height)
for i in range(xsize):
for j in range(ysize):
square = Square(side=side * gradient[j])
points = square.coords() + lattice.coords(i, j)
dwg.add(
dwg.polygon(
points = points,
fill=fill,
stroke=stroke,
opacity=opacity,
)
)
return dwg
Redrawing the previous graphic with the updates gives the expected result where all the squares are now centered around lattice positions.
With a slight modification to the linear_gradient
function we can make the gradient symmetric with respect
to the center. I'm calling it ridge
gradient, and it should create a fade effect going both up and down.
def ridge(size: int, geom: bool = False, start: float = 0.01):
c = round(size / 2)
if geom:
return np.hstack((
np.geomspace(start, 1, size - c),
np.geomspace(start, 1, c)[::-1]
))
else:
return np.hstack((
np.linspace(start, 1, size - c),
np.linspace(start, 1, c)[::-1]
))
Applying the ridge gradient give a nicely symmetric pattern.
I guess it's easy to see how to modify the current code to change the direction or produce a different fade pattern. In the next step we're going to update the gradient from 1D to 2D to allow for more sophisticated patterns.
So far we've applied gradient along a single axis, but there is nothing preventing
us from extending the gradient to 2D. One familiar concept we can try to implement is radial gradient.
We'll need to modify the function for drawing the squares to modify the size of squares
based on both i
and j
lattice positions, and we'll need a 2D array with sizes
representing brightness. The radial_gradient
function evaluates the distance between
the origin
of the gradient and given lattice position and scales the value to [0.0, 1.0]
range within the specified radius
.
def radial_gradient(
lattice: Lattice,
origin: np.ArrayLike,
radius: float,
width: int,
height: int) -> npt.ArrayLike:
"Compute brightness for a radial gradient"
def radial_distance(origin, coords, radius):
distance = euclidean(origin, coords)
return min(distance / radius, 1.0)
xsize, ysize = lattice.size(width, height)
gradient = np.ones((xsize, ysize))
for i in range(xsize):
for j in range(ysize):
gradient[i, j] = min(
gradient[i, j],
radial_distance(origin, lattice.coords(i, j), radius)
)
return gradient
Now the returned array is 2D and can be applied to our grid. The modification to draw_squares
is straightforward, so I'll not repeat the code here. Since the number in the gradient array
are scaled to [0.0, 1.0]
we can also compute the inverse gradient by subtracting the gradient from 1.
Here are the two variants we get:
A nice effect here is that side by side these look like the right hand side one is "taken out" from the left hand side one.
Square lattice is not the only choice for a lattice. It's the most symmetric looking, and therefore it has an artificial or dry aesthetic. To check the effect that a lattice might have on the overall graphic I'll try also a hexagonal lattice also known as honeycomb structure.
Just as in the case of a square lattice we need only a single parameter a
to define the lattice.
Since for the hexagonal lattice the translation vectors are no longer parallel we need to take that
into account when computing lattice coordiantes^{4}.
@dataclass
class HexLattice:
"Hexagonal lattice"
a: float
@property
def vectors(self):
return np.array([[self.a, 0], [self.a / 2, self.a * np.sqrt(3) / 2]])
def coords(self, row: int, col: int) -> npt.ArrayLike:
"Coordinates for a lattice position (row, col)"
return self.vectors[0] * row + self.vectrors[1] * col
With the lattice defined I would like to have regular hexagons in cell positions
in analogy to having squares on a square lattice. For a regular hexagon with the
side a
here is a diagram with the coordinates of the vertices with respect to the center of
the hexagon^{5}.
Based on that we can code up the Hexagon
class to be drawn on the canvas as the
polygon
element.
@dataclass
class Hexagon:
"Regular Hexagon"
side: int
@property
def height(self):
"Height of a flat top regular hexagon"
return math.sqrt(3) * self.side
def coords(self):
"Coordinates of vertices with repect to hexagon center (0, 0)"
return np.array([
(-self.side / 2, -self.height / 2),
(-self.side, 0),
(-self.side / 2, self.height / 2),
(self.side / 2, self.height / 2),
(self.side, 0),
(self.side / 2, -self.height / 2),
])
With that we should be able to recreate the analogues of the graphics we created
with squares on a square lattice. We'll also need a draw_hexagons
function, but
that again is pretty analogous to draw_squares
, so I'll leave it for you to figure adapt.
With all things in place here is a side by side comparison of the graphics with and without different gradient effects. You might notice that there is an extra wavy pattern generated which is also at the head of this post. It's essentially a sin wave with a fade based on distance from the curve. If you're curious about it let me in the comments and I'll more.
Generating vector graphics programmatically was a lot more exciting than I expected and I was surprised how much I got sucked into it. Once it was clear how to create shapes and position them on the canvas create variations on the designs was quite easy. I am quite happy with the results so far and definitely prefer the hex-on-hex variant, but I would like to explore further and try out a few more things to make the designs a bit more organic and less perfect.
To take it further I would probably explore ways of introducing randomness in various channels and to encode different attributes. So far all the designs were quite simple and deterministic and randomness would take them to next level. Breaking the perfect symmetries either by deforming the lattice of making the shapes a bit irregular (or both) might also produce an interesting result.
On top of that I think that introducing various kinds of defects and imperfections inspired by real crystal structures where those phenomena occur would add yet another dimension to explore.
This is going to be a nice journey back in to my crystallgraphy lectures. ↩
By default SVG coordinates start in the top-left corner at (0, 0)
and go to (width, height)
with the pixels as unit. ↩
In general there are 5 types Bravais lattices in 2D, and they can be specified by defining the parameters of the unit cell. ↩
If you want to know learn more about hexagonal coordinates check out this post. ↩
I choose the flat top orientation of the hexagon, an alternative would be the pointy top hexagon orientation. They differ by a 90-degree rotation of all vertices, so it's quite straightforward to switch between them. ↩