I created an interactive generative line art creator in Python with the Bokeh library. I was inspired by the “Locations of Lines” artworks by Sol LeWitt I recently saw at the Chicago Museum of Contemporary Art.
Bokeh is great because it allows you to create D3 style interactive plots within Python. So this was a great opportunity to try out the library and get a little creative with Numpy arrays. Before I get into the code, I’ll show the final result:
I wanted to be able to modify several aspects of the plot in real time: line length and gap length, line width, row and column density, and the line colors. Updating some of these parameters requires different things happening in the backend. For example, changing line thickness is trivial, but changing line length involves completely modifying the underlying data that is being plotted. This post will outline how it all works. If you want to check out the full code, here’s the repo. The code here differs slightly from the repo since this is just to communicate the basic ideas.
Creating a single row or column of data
First, I’ll outline how I create the data row a single row of lines. Each line needs a start and end point, with each point having an X and Y coordinate. To create the X coordinates, I just need the line length and gap length. Then, with the help of some fancy Numpy indexing, I can create two arrays: the X-coordinates for start of each line and the X-coordinates for the end of each line. The Y coordinates are easy since we’re only creating lines for one row. They’re all just the same row number.
length_row = 1000 row = np.arange(1000) row_iter = 1 line_length = 250 line_gap = 50 #make each row differ slightly start_jitter = np.random.choice(np.arange(line_length+line_gap)) #grab coordinates coordinates1 = row[start_jitter::line_gap+line_length] coordinates2 = row[start_jitter+line_length::line_gap+line_length] #make sure coordinate arrays match dimensions if coordinates1.shape > coordinates2.shape: coordinates1 = coordinates1[0:-1] #combine and transpose lines1 = np.vstack((coordinates1,coordinates2)).T #create matching coordinates lines2 = np.ones(lines1.shape)*row_iter
To create columns of data, I simply swap the X and Y coordinates. Originally I had two sets of functions for creating rows and columns. But I realized they were 99% identical functions so I merged them. With some tweaks, I stuck the above code in a function called
Creating all rows/columns of data
Next, I needed to go row-by-row and create each row of data. Here’s where row and column density come into play. Basically, I wanted to be able to manipulate how close each row/column was to each other. This is easily implemented by iterating over the rows while using the density as the step size. So, create one row, skip 4, and create another. I love Numpy.
for row in rows[::row_density]: if row == 0: lines1,lines2 = _generate_row_col_lines(row) else: a,b = self._generate_row_col_lines(row_col) lines1 = np.concatenate((lines1,a)) lines2 = np.concatenate((lines2,b)) lines1,lines2 = lines1.tolist(),lines2.tolist()
I put the above lines in a function called
_generate_all_lines(), then put everything into a class called
Plotting with Bokeh
Okay, so now that I have the data, I want to plot it. Bokeh is very well-documented, so plotting was pretty easy. I have experience in Matplotlib, ggplot2 in R, and MATLAB plotting, so I feel pretty confident going into new plotting libraries.
First I create the
LineFactory class, then setup the data. For plotting, I used the
MultiLine function to plot all of the rows at once. Then a second call plots all of the columns.
# create instance of class lines = LineFactory( line_length=250, line_gap=50, column_density=80, row_density=80) # setup data horizontal_source = ColumnDataSource(data=dict( horizontal_lines_xs=lines.horizontal_lines_xs, horizontal_lines_ys=lines.horizontal_lines_ys)) vertical_source = ColumnDataSource(data=dict( vertical_lines_xs=lines.vertical_lines_xs, vertical_lines_ys=lines.vertical_lines_ys)) # Set up plot plot = lines.create_figure() horizontal_lines = plot.multi_line(xs = 'horizontal_lines_xs', ys='horizontal_lines_ys',source=horizontal_source,line_width=1,color='black') vertical_lines = plot.multi_line(xs = 'vertical_lines_xs', ys='vertical_lines_ys',source=vertical_source,line_width=1,color='black')
Alright, here’s our default plot! Looks a bit like the original artwork I saw by Sol LeWitt. Now to make it interactive!
Adding interactivity using Bokeh server
Here’s the really interesting part. The Bokeh server allows me to create a plot with widgets that implement code to update the data that is being plotted. Changing line length, gap length, and density requires the data to be updated on-the-fly as the widget is used. The color picker and line thickness are much easier to implement since they are just plotting aesthetics.
First, I create the widgets that will affect the underlying data being plotted.
# data widgets line_length = Slider(title="Line Length", value=250, start=0, end=1000, step=10) line_gap = Slider(title="Line Gap", value=50, start=0, end=1000, step=10) row_density = Slider(title="Row Density", value=80, start=0, end=90, step=10) column_density = Slider(title="Column Density", value=80, start=0, end=90, step=10)
And then create an update function that updates the data for the plot as the widget’s value is changed.
# Set up callbacks def update_data(attrname, old, new): # Get the current slider values ll = line_length.value lg = line_gap.value rd = row_density.value cd = column_density.value lines.make_lines(ll,lg,rd,cd) horizontal_source.data = dict( horizontal_lines_xs=lines.horizontal_lines_xs, horizontal_lines_ys=lines.horizontal_lines_ys) vertical_source.data = dict( vertical_lines_xs=lines.vertical_lines_xs, vertical_lines_ys=lines.vertical_lines_ys) for w in [line_length, line_gap, row_density, column_density]: w.on_change('value', update_data)
The widgets that affect the aesthetics (line thickness and color) are much easier. Using the
jslink, I connected the widgets to the parameters of the lines I wanted to change.
# plotting widgets row_color_widget = ColorPicker(title="Row Color") column_color_widget = ColorPicker(title="Column Color") row_color_widget.js_link('color', horizontal_lines.glyph, 'line_color') column_color_widget.js_link('color', vertical_lines.glyph, 'line_color') line_thickness = Slider(title="Line Thickness", value=1, start=0, end=10, step=1) line_thickness.js_link('value', vertical_lines.glyph, 'line_width') line_thickness.js_link('value', horizontal_lines.glyph, 'line_width')
Finally I put all of the widgets together with the plot. And
curdoc().add_root() sets up the Bokeh server with this Python code running as the backend.
inputs = column(line_length, line_gap, line_thickness, row_density, column_density, row_color_widget, column_color_widget) curdoc().add_root(row(inputs, plot, width=800))
Then in the terminal. This opens the interactive plot in your default browser.
cd ...\locations_of_lines bokeh server --show locations_of_lines.py
This was a really fun project. I haven’t ever created an interactive plot before, so this was exciting. I love the idea of using interactive plots to aid in data exploration and model interpretation, so this is a skill I was happy to work on. It also reaffirmed my love of Numpy. The plot would be laggy if I couldn’t update the data over and over extremely quickly, and Numpy made that really easy.
I actually had fun messing around with the app for a while. Here are a few examples of artworks I was able to make with it: