Project 3: Generative Art
In this project you’ll build a program that generates original artwork using code. By the end, your program will place random shapes on a canvas and save the result as an image file.
You’ll build it one piece at a time over five phases — each phase adds something new to the project.
Phase 1 — Define Your Color Palette
What you’ll use: Lists, Dictionaries
Your first task is to define the colors your artwork will use. Colors in Pillow are tuples of (red, green, blue) — each value is between 0 and 255.
You can use Coolors to generate a palette, but you’ll need to convert the hex codes to RGB tuples (e.g. #FF5733 → (255, 87, 51)). This hex to RGB converter can help.
Tasks:
Phase 2 — Draw Random Circles
What you’ll use: random, Pillow
Install Pillow, create a blank canvas, and use random to scatter circles across it. Your goal is to get an image file saved to your computer by the end of class.
Tasks:
Hint — drawing random circles
You’ll need a loop that runs once per circle. Inside the loop, generate a random x, y, size, and color — then draw the circle.
repeat 50 times:
pick a random x between 0 and canvas width
pick a random y between 0 and canvas height
pick a random size (e.g. between 10 and 100 pixels)
pick a random color from the values in your palette dictionary
draw a circle at (x, y) with that size and colorimg and draw are objects — variables that bundle data and behavior together. draw.circle() is a method — a function that belongs to the draw object. You’ll learn more about this in Phase 3.
Phase 3 — Shape Classes
What you’ll use: Classes
Right now your drawing code generates circles using loose variables — x, y, size, and color are separate values scattered through your loop. The goal this class is to bundle that data together using classes.
Instead of generating random values and immediately drawing them, you’ll create a Circle object that holds its own position, size, and color, and knows how to draw itself. This sets you up for Phase 4 where a Canvas will manage a whole collection of shapes.
What to migrate:
In Class 2 your loop probably looked something like this:
for i in range(50):
x = random.randint(0, width)
y = random.randint(0, height)
size = random.randint(10, 100)
color = random.choice(list(palette.values()))
draw.circle((x, y), radius=size, fill=color)By the end of this class, that same circle will be represented as an object:
circle = Circle(x, y, size, color)
circle.draw(draw)Then you can create 50 Circle objects in a loop and draw each of them.
Tasks:
Stretch goal — Add a Rectangle class:
draw_context is the ImageDraw object you created in Phase 2 with ImageDraw.Draw(img). When you call circle.draw(draw), you’re passing that object in so the shape can use it to draw itself onto the canvas. The shape doesn’t create it — it just receives it and uses it.
Hint — structuring a shape class
class Circle:
init takes: x, y, radius, color
store each as an attribute (self.x, self.y, etc.)
draw takes: draw_context
use draw_context to draw a circle using self.x, self.y, self.radius, self.colorPhase 4 — The Canvas Class
What you’ll use: Classes, Lists, Loops, Polymorphism
Now build a Canvas class that stores a collection of shapes and renders them all. Here’s the key idea: Canvas doesn’t need to know whether a shape is a Circle or a Rectangle — it just calls draw() on each one and they handle the rest.
What to migrate:
In Phase 3 your code probably looked something like this — creating the image, draw context, and loop all at the top level:
create a blank image with width, height, and background_color
create a draw context from the image
repeat 50 times:
pick a random x, y, size, and color
create a Circle with those values
call circle.draw(draw context)
save the image to "output.png"By the end of this phase, all of that setup and teardown moves into Canvas:
create a Canvas with width, height, and background_color
repeat 50 times:
pick a random x, y, size, and color
create a Circle with those values
call canvas.add_shape(circle)
call canvas.render("output.png")Canvas now owns the image, the draw context, and saving the file — your top-level code just adds shapes and calls render().
Tasks:
Hint — the render loop
render(filename):
create a draw context from the image
for each shape in self.shapes:
call shape.draw(draw_context)
save the image to filenameNotice that render doesn’t check what type of shape it has — it just calls draw(). This works because both Circle and Rectangle have a draw() method.
Phase 5 — Polish and Present
Put the finishing touches on your piece and get ready to show it.
Ideas for polishing:
- Adjust the ratio of each shape type to change the feel of the piece
- Vary size based on position (e.g. smaller shapes toward the edges)
- Add a title to your image using
ImageDraw.text() - Generate several variations and pick the best one
Tasks:
Stretch goal — Refactor with inheritance:
Notice that Circle and Rectangle both store x, y, and color in their __init__. That’s repeated code. Create a Shape base class that holds those shared attributes, then have Circle and Rectangle inherit from it.