The Invent with Python Blog

Writings from the author of Automate the Boring Stuff.

Algorithmic Art with the BitFieldDraw Module

Mon 02 August 2021    Al Sweigart

article header image

A bit field is a 2D image of black and white pixels. It's the simplest possible image. The pixels don't necessarily have to be black and white, but a bit field is limited to two colors because each pixel is represented by a bit: a 0 or 1 number. You can install it by running pip install --user bitfielddraw (on Windows) or pip3 install --user bitfielddraw (on macOS and Linux). In the interactive shell, run import bitfielddraw and bitfielddraw.main() to see a demo. (Maximize your terminal window for best effect.)

Despite this limitation, bit fields can be beautiful. I found this twitter thread (and MetaFilter discussion) that had several bit fields generated by simple mathematical functions. The functions are fed the X and Y coordinate of a pixel, and if the function returns a zero the pixel is white and if the function returns a nonzero the pixel is black. There are several interesting patterns that can be generated from simple functions:

Inspired by this, I've created a Python module called BitFieldDraw, which makes it easy to see the bit fields generated from a given function. You can from bitfielddraw import * and then call the save() (to save it to an image or text file), p() functions to print it to the terminal screen as block text characters, or s() (to show the image using the Pillow module).

The function that generates the art can be a Python lambda function such as lambda x, y: (x ^ y) % 5 or simply a string with the body of the lambda function such as '(x ^ y) % 5'.

The function (x ^ y) % 5 xor's (that is, exclusive or's) the x and y coordinates, then mods the result by 5. Let's use the coordinates (10, 19). This produces the operation (10 ^ 19) % 5. 10 and 19 are 01010 and 10011 in binary. When we xor them, we get 11001 or 25. 25 mod 5 is 0 (that is, 25 divided by 5 has a remainder of 0). Because 0 is a zero value, this produces a white pixel at the coordinates (10, 19).

The BitFieldDraw runs this calculation for every pixel in a 2D field to draw algorithmic art. Some examples:

>>> save('bitfield1.png', '(x ^ y) % 5', w=400, h=400)

>>> save('bitfield2.png', '(x * 64) % y', w=400, h=400)

>>> save('bitfield3.png', '(x % y) % 4', w=400, h=400)

>>> save('bitfield4.png', '(x & y) & (x ^ y) % 19', w=400, h=400)

When you save bit fields as image files, you can give them any two colors for the foreground and background colors:

>>> save('bitfield-color1.png', '(x & y) & (x ^ y) % 19', w=400, h=400, fg='red', bg='black')

>>> save('bitfield-color2.png', '(x & y) & (x ^ y) % 19', w=400, h=400, fg='yellow', bg='blue')

>>> save('bitfield-color3.png', '(x & y) & (x ^ y) % 19', w=400, h=400, fg='#004400', bg='green')

The first argument to p() print-function and s() save-function are an expression with x and y variables. The expression evaluates to the color for that pixel: black if zero and white if nonzero. The width and height of the bit field are automatically set to the size of the terminal window. Since text cells in terminal windows are twice as tall as they are wide, each text cell represents two pixel. However, other integers values can be passed for the w and h parameters to the functions to change the width and height.

Since text cells in terminal windows are twice as tall as they are wide, each text cell actually represents two pixels in one column and two rows. The text characters used are code points 9600 (just the top pixel set '▀'), 9604 (just the bottom pixel set '▄'), and 9608 (both pixels set '█'). For both pixels being clear, we use a space character ' '.

The 0, 0 origin of the bit field is set to the bottom left corner. The origin can be changed by passing integers to the x and y parameters.

>>> p('(x^y)%5', w=10, h=10)
██▀▄██▄▀▀▄
██▄▀██▀▄▄▀
▄▀██▀▄████
██▀▄██▄▀▀▄
▀▄██▄▀████
>>> p('(x^y)%5', w=10, h=10, x=2)
▀▄██▄▀▀▄██
▄▀██▀▄▄▀██
██▀▄████▄▀
▀▄██▄▀▀▄██
██▄▀████▀▄
>>> p('(x^y)%5', w=10, h=10, y=2)
▀▄██▄▀████
██▀▄██▄▀▀▄
██▄▀██▀▄▄▀
▄▀██▀▄████
██▀▄██▄▀▀▄

Unlike computer screen coordinates, the bitfielddraw module uses mathematics coordinates so that the Y coordinates increase going up, not down. You can change this by passing flipy=True to any of the BitFieldDraw functions, in which case the y coordinates will increase going down. You can also pass flipx=True to flip the x coordinates. (Currently there's no way to rotate the bit fields.)

>>> p('(x^y)%5', w=10, h=10)
██▀▄██▄▀▀▄
██▄▀██▀▄▄▀
▄▀██▀▄████
██▀▄██▄▀▀▄
▀▄██▄▀████
>>> p('(x^y)%5', w=10, h=10, flipx=True)
▄▀▀▄██▄▀██
▀▄▄▀██▀▄██
████▄▀██▀▄
▄▀▀▄██▄▀██
████▀▄██▄▀
>>> p('(x^y)%5', w=10, h=10, flipy=True)
▄▀██▀▄████
██▄▀██▀▄▄▀
▀▄██▄▀████
██▀▄██▄▀▀▄
██▄▀██▀▄▄▀
>>> p('(x^y)%5', w=10, h=10, flipx=True, flipy=True)
████▄▀██▀▄
▀▄▄▀██▀▄██
████▀▄██▄▀
▄▀▀▄██▄▀██
▀▄▄▀██▀▄██

If you'd like to swap the colors used, you can pass True for the invert parameter:

>>> p('(x^y)%5', w=10, h=10)
██▀▄██▄▀▀▄
██▄▀██▀▄▄▀
▄▀██▀▄████
██▀▄██▄▀▀▄
▀▄██▄▀████
>>> p('(x^y)%5', w=10, h=10, invert=True)
  ▄▀  ▀▄▄▀
  ▀▄  ▄▀▀▄
▀▄  ▄▀
  ▄▀  ▀▄▄▀
▄▀  ▀▄

You can also get the string of the bit field directly with getBitFieldStr():

>>> import bitfielddraw
>>> bitfielddraw.printBitFieldStr('(x ^ y) % 5', w=10, h=10)  # This is the same as the p() function.
██▀▄██▄▀▀▄
██▄▀██▀▄▄▀
▄▀██▀▄████
██▀▄██▄▀▀▄
▀▄██▄▀████
>>> bitfielddraw.getBitFieldStr('(x ^ y) % 5', w=10, h=10)
'██▀▄██▄▀▀▄\n██▄▀██▀▄▄▀\n▄▀██▀▄████\n██▀▄██▄▀▀▄\n▀▄██▄▀████'

getBitField(), getBitFieldStrFromSet(), and getBitFieldImgFromSet

If you'd like to get the raw data of the bit field, you can call getBitField() which will return a frozen set of (x, y) tuples. From this, you can make changes to the data and then pass it to getBitFieldStrFromSet() or getBitFieldImgFromSet() to get a string or Pillow image object of the modified bit field.

>>> import bitfielddraw
>>> bitFieldData = bitfielddraw.getBitField(lambda x, y: (x^y)%5, width=20, height=20)
>>> bitFieldData
frozenset({(7, 17), (18, 17), (8, 0), (19, 0), (8, 9), (19, 9), (11, 5), (8, 18), (19, 18), (0, 14), (4, 2), (3, 15), (14, 15), (15, 7), (7, 3), (18, 3), (15, 16), (7, 12), (8, 4), (19, 4), (11, 0), (0, 9), (11, 9), (3, 1), (3, 10), (14, 10), (3, 19), (14, 19), (15, 2), (15, 11), (18, 7), (7, 16), (18, 16), (10, 8), (10, 17), (3, 5), (14, 5), (3, 14), (15, 6), (18, 2), (7, 11), (6, 15), (10, 3), (10, 12), (2, 17), (3, 0), (14, 0), (14, 9), (3, 18), (14, 18), (6, 10), (6, 19), (10, 7), (2, 3), (10, 16), (2, 12), (3, 4), (17, 6), (9, 11), (6, 5), (6, 14), (10, 2), (10, 11), (2, 16), (16, 18), (17, 1), (5, 8), (17, 10), (17, 19), (9, 15), (6, 0), (10, 6), (2, 11), (1, 15), (13, 17), (16, 13), (5, 3), (9, 1), (5, 12), (17, 14), (9, 10), (9, 19), (6, 4), (6, 13), (13, 3), (1, 10), (13, 12), (16, 8), (1, 19), (16, 17), (17, 0), (5, 7), (17, 9), (9, 5), (5, 16), (9, 14), (12, 15), (1, 5), (16, 3), (13, 16), (16, 12), (5, 2), (17, 4), (9, 0), (5, 11), (0, 18), (12, 1), (12, 10), (4, 6), (12, 19), (4, 15), (1, 0), (1, 9), (13, 11), (16, 7), (1, 18), (5, 6), (19, 8), (0, 4), (19, 17), (0, 13), (11, 13), (12, 5), (12, 14), (4, 10), (4, 19), (16, 2), (1, 13), (8, 3), (19, 3), (19, 12), (8, 12), (0, 8), (11, 8), (0, 17), (11, 17), (12, 0), (4, 5), (15, 1), (7, 6), (15, 19), (7, 15), (18, 15), (0, 3), (11, 3), (19, 16), (8, 16), (0, 12), (11, 12), (12, 4), (4, 0), (4, 9), (3, 13), (14, 13), (7, 1), (18, 1), (15, 14), (7, 10), (18, 10), (18, 19), (19, 2), (8, 11), (19, 11), (0, 7), (11, 7), (0, 16), (11, 16), (3, 8), (14, 8), (3, 17), (14, 17), (15, 9), (7, 5), (18, 5), (15, 18), (7, 14), (18, 14), (19, 6), (0, 2), (11, 2), (14, 3), (14, 12), (15, 4), (7, 0), (18, 0), (15, 13), (7, 9), (18, 9), (10, 1), (2, 6), (2, 15), (3, 7), (14, 7), (3, 16), (17, 18), (7, 4), (18, 4), (6, 8), (6, 17), (2, 1), (10, 14), (2, 10), (2, 19), (3, 2), (14, 2), (3, 11), (17, 13), (9, 18), (10, 9), (2, 5), (10, 18), (2, 14), (14, 6), (9, 4), (9, 13), (6, 7), (6, 16), (10, 4), (2, 0), (10, 13), (2, 9), (13, 6), (2, 18), (13, 15), (16, 11), (5, 1), (17, 3), (17, 12), (9, 8), (5, 19), (9, 17), (6, 2), (6, 11), (2, 4), (13, 1), (1, 8), (13, 10), (16, 6), (1, 17), (16, 15), (17, 7), (5, 14), (17, 16), (12, 13), (4, 18), (1, 3), (13, 5), (16, 1), (1, 12), (13, 14), (16, 10), (16, 19), (17, 2), (5, 9), (17, 11), (9, 7), (5, 18), (6, 1), (12, 8), (12, 17), (4, 13), (13, 0), (1, 7), (13, 9), (16, 5), (1, 16), (13, 18), (5, 4), (9, 2), (5, 13), (8, 6), (8, 15), (19, 15), (0, 11), (4, 8), (4, 17), (1, 2), (13, 4), (16, 0), (7, 18), (8, 1), (19, 1), (8, 10), (0, 6), (11, 6), (8, 19), (11, 15), (12, 7), (4, 3), (12, 16), (4, 12), (1, 6), (15, 8), (18, 13), (8, 5), (19, 5), (0, 1), (8, 14), (19, 14), (11, 10), (0, 19), (11, 19), (12, 2), (12, 11), (4, 7), (15, 3), (15, 12), (18, 8)})

(Currently, getBitFieldStrFromSet() isn't implemented.)

Examples

BitFieldDraw comes with several examples in its EXAMPLES member:

>>> import bitfielddraw
>>> import pprint
>>> pprint.pprint(bitfielddraw.EXAMPLES)
('(x ^ y) % 5',
 '(x ^ y) % 9',
 '(x ^ y) % 17',
 '(x ^ y) % 33',
 '(x ^ y) % 2',
 '(x ^ y) % 4',
 '(x ^ y) % 8',
 '(x | y) % 7',
 '(x | y) % 17',
 '(x | y) % 29',
 '(x * y) & 64',
 '(x * y) & 24',
 '(x * y) & 47',
 '(x ^ y) < 77',
...

You can view a slideshow of these examples by running bitfielddraw.main(). I recommend maximizing your terminal window for the largest view.


Learn to program for free with my books for beginners:

Sign up for my "Automate the Boring Stuff with Python" online course with this discount link.

Email | Mastodon | Twitter | Twitch | YouTube | GitHub | Blog | Patreon | LinkedIn | Personal Site