Interactive Bouncing Ball Installation

by rewchao in Design > Software

79 Views, 5 Favorites, 0 Comments

Interactive Bouncing Ball Installation

Interactive Bouncing Balls Installation

⚠️AI was NOT used to generate the code for this project, while I have nothing against the use of AI, since this is for a contest, I feel its necessary to mention this. Happy reading :)⚠️


For the theme of the Make It Bounce contest I wanted to create an interactive physics based ball bouncing installation. This project aimed to allow people to dynamically create obstacles by placing and removing masking tape. The obstacles would act as surfaces where randomly generated balls could fall and bounce off their surfaces, allowing for the creation of an infinite number of unique setups.

Supplies

Materials

  1. Colored Masking Tape

Electronics:

  1. Laptop with Python installed
  2. Projector
  3. External Webcam or Cellphone Camera

Project Overview

While primary principal of the project is quite simple, it touches into multiple fields, from programming and linear algebra, to art and music theory. The multidisciplinary aspect of this project is personally the reason I enjoyed working on this project so much.

Physical Setup

Screenshot 2026-05-31 201723.png

The physical setup is quite simple. For the webcam I opted to use my cell phone instead of a webcam. The quality of the webcam is quite paramount for this project as compromised quality in low light settings like this will reduce the accuracy and precision for the detection of the masking tape.

Libraries

Screenshot 2026-05-31 203834.png

Everything in this project is done in python. Mainly for simplicity since this project doesn't need great optimization.


  1. Pygame was used to create the final 'game' window to render the balls.
  2. Pymunk was used to simulate physics on our ball objects, pymunk and pygame combo is quite common and there is a lot of documentation on how to use them together.
  3. OpenCV-python (CV2) was used for the majority of computing, it was used to grab frames from the webcam, straighten and map the frame (explained later in step 5), and get the final polygon points of the tape
  4. Pyo by Olivier Bélanger was used to generate the ambient background noise as well as the ball bouncing audio

Logic Flow Outline

Screenshot 2026-05-31 210122.png

The logic flow above shows the function of each of our libraries in the entire project. In the next steps we will look more in detail how we handle each component of the logic in the order below:

  1. cv2 (Computer Vision Processing)
  2. pymunk (physics) + pygame (rendering)
  3. pyo (audio)

CV2 Preface: Homography Matrices

Screenshot 2026-05-31 212315.png

Since our camera frame isn't perfectly aligned with the projectors frame (that would be impossible to do!) we need to be able to warp our cameras frame back into the projectors frame.


Technically you don't need any linear algebra knowledge for this step but I think the theory behind it is super cool!


Fundamentally our camera frame is just a bunch of pixels with coordinates [x;y] to get it back into our projectors frame we want to transform all the coordinates to new coordinates in a very specific way. To do this we will use something called a homography matrix. A homography matrix is simply a 3x3 transform used to plot the points from one planar surface to another planar surface.


Homography = [h11 h12 h13; h21 h22 h23; h31 h32 h33]

P = [x;y;1]


We can find:

P' = Homography P


Notice how the 1 becomes w? We need to bring the point back onto the same plane as the original point which means we need to scale y'/w and x'/w to get [x';y';1]


If you want to learn more about how these transforms work more intuitively. I would check out affine transformations. Affine transformations are similar to Homography transformations but slightly more simple as they conserve parallelism. You can quite intuitively see the different transforms like: shear, x, y, x scale, y scale.


Affine Transformation Wiki: https://en.wikipedia.org/wiki/Affine_transformation

Homography Wiki: https://en.wikipedia.org/wiki/Homography_(computer_vision)

CV2 Color Calibration

Screenshot 2026-05-31 221416.png

The first step in the program is to find the correct color range of the tape.


Since we are filtering color we will be using HSV (Hue, Saturation, Value) to filter out our color. We use HSV since colors are neatly divided by the angle. Typically this is visualized as a cylinder but the HSV graphic above is a inverted cone, but alas they are similar.


In CV2 we create a new window with 6 sliders - each controlling a lower and upper bound for H, S, and V.

CV2 is a bit weird where Hue goes from 0-179 instead of 0-360 as you would expect. Both Saturation and Value are from 0-255.

def do_nothing(val):
pass

def get_bounds():
h_lower = cv2.getTrackbarPos("hue min", 'Color Calibration Window')
h_upper = cv2.getTrackbarPos("hue max", 'Color Calibration Window')

s_lower = cv2.getTrackbarPos("sat min", 'Color Calibration Window')
s_upper = cv2.getTrackbarPos("sat max", 'Color Calibration Window')

v_lower = cv2.getTrackbarPos("val min", 'Color Calibration Window')
v_upper = cv2.getTrackbarPos("val max", 'Color Calibration Window')

lower_bound = np.array([h_lower, s_lower, v_lower])
upper_bound = np.array([h_upper, s_upper, v_upper])

#updates lower and upper
def calibrate_color(camera_num, current_lower_bound, current_upper_bound):
lower_bound, upper_bound = current_lower_bound, current_upper_bound

capture = cv2.VideoCapture(camera_num)
cv2.namedWindow('Color Calibration Window')

#trackerbars
cv2.createTrackbar("hue min", 'Color Calibration Window', lower_bound[0], 179, do_nothing)
cv2.createTrackbar("hue max", 'Color Calibration Window', upper_bound[0], 179, do_nothing)

cv2.createTrackbar("sat min", 'Color Calibration Window', lower_bound[1], 255, do_nothing)
cv2.createTrackbar("sat max", 'Color Calibration Window', upper_bound[1], 255, do_nothing)

cv2.createTrackbar("val min", 'Color Calibration Window', lower_bound[2], 255, do_nothing)
cv2.createTrackbar("val max", 'Color Calibration Window', upper_bound[2], 255, do_nothing)

#instructions
text = "Color Calibration: press ENTER to save"
position = (15, 30)
font = cv2.FONT_HERSHEY_SIMPLEX
font_scale = 0.5
color = (0, 255, 0)
thickness = 2


while True:
ret, frame = capture.read()
if not ret:
print("failed to capture video")
break

#mask
lower_bound, upper_bound = get_bounds()
hsv_frame = cv2.cvtColor(frame,cv2.COLOR_BGR2HSV)
mask = cv2.inRange(hsv_frame, lower_bound, upper_bound)

#combine original & mask
masked_frame = cv2.bitwise_and(frame, frame, mask=mask)

#display text
cv2.putText(masked_frame, text, position, font, font_scale, color, thickness, cv2.LINE_AA)

#display video
cv2.imshow('Color Calibration Window', masked_frame)

if cv2.waitKey(1) & 0xFF == 13:
break

cv2.destroyAllWindows()

print("Calibrated Color")
#like c++ pass by reference (i think loll)
current_lower_bound[:] = lower_bound
current_upper_bound[:] = upper_bound


In the lower window we provide a live camera feed + the mask so the user is able to easily calibrate the colors. In the end we are left with a Lower and Upper bound for HSV which we will use later to mask out the platforms.


Note since we are using only a min and max for Hue - it will be difficult to mask out Red as red lies in between around 179 and 0 - meaning if we wanted to mask red, we would need two ranges for hue.

CV2 Frame Mapping

Screenshot 2026-05-31 222545.png

While the frame mapping is the most logically complex part of this project. CV2 handles all the grunt work and provides us with a ton of very easy to use functions! (no matrix math needed ha!)


Find Calibration Points

First we need to find the calibration points, to find the calibration points we bring up a window using CV2, then allow the user to click the four corners of the projectors projection. We save these points into a array. These become our source points


Calculate Homography Matrix

Now we have our source points we calculate our destination points which is just the coordinates for the corners of the pygame window. We can use cv2.getPerspectiveTransform(source_points, destination_points) to calculate the 3x3 homography matrix.

def calculate_homography_matrix(calibration_points,pygame_width, pygame_height):
source_points = np.array(calibration_points, dtype=np.float32)
destination_points = np.array([[0, 0], [pygame_width, 0], [pygame_width, pygame_height], [0, pygame_height]], dtype=np.float32)
homography_matrix = cv2.getPerspectiveTransform(source_points, destination_points)

return homography_matrix



Map frame

Now we have our homography matrix we need to map our original webcam frame to our new mapped frame. CV2 makes this very easy we can use the cv2.warpPerspective function to do this. This returns our new warped (mapped) frame

def map_frame(original_frame, homography_matrix, pygame_width, pygame_height):
mapped_frame = cv2.warpPerspective(original_frame, homography_matrix, (pygame_width, pygame_height))
return mapped_frame

CV2 Masking + Grabbing Polygons

Screenshot 2026-05-31 223707.png

Color Masked Frame

Now we have our mapped frame - we need to create polygons around objects in our color range.

To filter out the color range we can use the cv2.inRange function which takes in our HSV frame and the lower and upper bounds we found previously in the color calibration section.


Don't forget to convert from RGB to HSV!

def _get_mask(frame, lower_bound, upper_bound):
hsv_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
masked_frame = cv2.inRange(hsv_frame, lower_bound, upper_bound)

return masked_frame


Get Platform Polygons

Now we have our color masked frame we can use cv2.findContours to get the contours of our masked areas. Notice in the image above how the color detection is quite noisy? To get rid of those tiny dots we can add an area filter so color detection shapes need to have an area > minimum to be added as a polygon. This greatly improves the results when creating platforms. You might need to play around with the area cutoff to get the best results


We save the polygons in our platform_polygons list to return

def _get_platform_polygons(frame, lower_bound, upper_bound):
accuracy = 0.001 #smaller means more points
minimum_area = 200 #minimum area threshold
platform_polygons = []

masked_frame = _get_mask(frame, lower_bound, upper_bound)
contour_list, ret = cv2.findContours(masked_frame, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

for contour in contour_list:
contour_perimeter = cv2.arcLength(contour, True)
epsilon = accuracy * contour_perimeter

polygon_approximation_of_contour_perimeter = cv2.approxPolyDP(contour, epsilon, True)
reshaped_polygon_approximation_of_contour_perimeter = polygon_approximation_of_contour_perimeter.reshape(-1, 2)

if cv2.contourArea(reshaped_polygon_approximation_of_contour_perimeter) > minimum_area:
platform_polygons.append(reshaped_polygon_approximation_of_contour_perimeter)

return platform_polygons

Pymunk + Pygame

Congrats! We've gotten over most of the hard stuff.


Pygame and Pymunk makes it very easy to setup a physics environment and render. Before setting up the environment I created two classes to represent the Ball and Platform class. For each I created a draw function to draw them into the pygame window.

#classes
class Ball:
def __init__(self, color, radius, inital_position, space):
self.color = color
self.radius = radius

#body
density = 0.01
mass = (np.pi * radius**2) * density
moment = pymunk.moment_for_circle(mass, 0, radius)
self.body = pymunk.Body(mass, moment)
self.body.position = inital_positio

#poly
self.poly = pymunk.Circle(self.body, radius)
self.poly.elasticity = 0.7
self.poly.friction = 0.5

#collision
self.poly.collision_type = 1 #COLLISION NUM 1

#add to space
space.add(self.body, self.poly)

#glow
self.glow_radius = self.radius * 2.5
self.glow_surf = pygame.Surface((self.glow_radius * 2, self.glow_radius * 2))

for i in range(int(self.glow_radius), 0, -1):
ratio = i / self.glow_radius
intensity = (1.0 - ratio) ** 2

r = int(self.color[0] * intensity)
g = int(self.color[1] * intensity)
b = int(self.color[2] * intensity)

pygame.draw.circle(self.glow_surf, (r, g, b), (self.glow_radius, self.glow_radius), i)
self.glow_surf.set_colorkey((0, 0, 0))

def draw(self, screen):
pygame.draw.circle(screen, self.color, self.body.position, self.radius)

def draw_glow(self, screen, size, intensity):
for i in range(1, intensity):
self.__draw_circle_alpha(screen, self.color, self.body.position, 25 + (i * size), 10)

def remove_from_space(self, space):
space.remove(self.body, self.poly)

def __draw_circle_alpha(self, surface, color, center, radius, alpha=255, width=0):
r, g, b = color[:3]

diameter = radius * 2
temp_surface = pygame.Surface((diameter, diameter), pygame.SRCALPHA)

pygame.draw.circle(temp_surface, (r, g, b, alpha), (radius, radius), radius, width)

target_x = center[0] - radius
target_y = center[1] - radius
surface.blit(temp_surface, (target_x, target_y))
class Platform:
def __init__(self, color, poly_points, space):
self.color = color

#body
self.body = pymunk.Body(body_type=pymunk.Body.STATIC)
self.body.position = (0,0)

#poly
poly_points = poly_points.reshape(-1, 2).tolist() #reshape to right format
self.poly = pymunk.Poly(self.body, poly_points)

self.poly.elasticity = 1.0 # 1.0 means it absorbs no energy (hard surface)
self.poly.friction = 0.5 # Gives the surface some grip

# collision
self.poly.collision_type = 2 # COLLISION NUM 2

#add to space
space.add(self.body, self.poly)

def draw(self, screen):
vertices = self.poly.get_vertices()

point_list = []
for v in vertices:
point = self.body.local_to_world(v)
point_list.append((int(point.x), int(point.y)))

pygame.draw.polygon(screen, self.color, point_list)

def remove_from_space(self, space):
space.remove(self.body, self.poly)


The Pygame and Pymunk game loop is very standard, there are a ton of examples online on how to setup the loop but heres a quick rundown:)


We have a primary loop with exit condition checks:

while Running:

#user event checker
for event in pygame.event.get():
if event.type == pygame.QUIT:
Running = False

if event.type == pygame.KEYDOWN:

# 'q' to quit
if event.key == pygame.K_q:
Running = False


Every frame we clear the frame by filling the screen white in pygame. Then we take a physics step in pymunk using space.step(time)

# take a physics step
space.step(1.0 / 60)

# fill screen
screen.fill("white")


I stored all the balls and platforms into a separate list so I could loop through and draw each of them indivually. I also created a kill list for the balls - when balls reached a certain distance offscreen I added them to the kill list to be removed and deleted properly from the list.


Another tip I found for better performance and results was to NOT update the platforms every frame. Instead I updated platforms every 5 frames. This improves the visual aspect of balls bouncing as before platforms would jitter ever so slightly each frame, causing issues where balls could clip into the platforms causing unexpected outcomes. Another benefit updating platforms every 5 frames is that you don't need to update the list every frame which can slightly improve performance.


One key note is that while you might cap you're framerate using pygame, the true framerate cap might be determined by your webcam as the program needs to grab a frame before continuing. Because of this when skipping updating platforms, I did not skip grabbing the frames and calculating new platforms to avoid issues where the framerate would flucutate from the webcams framecap and the pygames desired framecap. I am sure there is a better solution to this but I did not implement this as the current scope of this project doesn't focus too much on making things "efficient".

Pyo (Ambience + Ball Bouncing Noise)

Screenshot 2026-06-01 013219.png

I used Pyo developed by Olivier Bélanger to generate the Ambient background noise and Ball Bouncing Noise.


Ambience

The Ambience was quite straight forward, I played around with brown noise mixers online until I found a combination of frequencies that I liked. I ended up going for 3 different frequencies of brown noise: (60Hz, 170Hz and 310Hz). Originally I tested it on some old speaker which preformed quite poorly, so I recommend switching to some nicer speakers for this (preferably one with a subwoofer). Our home audio setup for our tv did just fine with the lower frequencies.


In Pyo is very easy to create brown noise, I just used the built in function, then pitched it to my 3 different frequencies. Adjust the multiplier to find your desired mix of frequencies.

#ambience
brown_noise = BrownNoise(mul=0.05)
floor_rumble = Tone(brown_noise, freq=120, mul=2.0).mix(2).out()

pitched_0 = Reson(brown_noise, freq=60, q=10, mul=5.0).mix(2).out()
pitched_1 = Reson(brown_noise, freq=170, q=10, mul=2.0).mix(2).out()
pitched_2 = Reson(brown_noise, freq=310, q=10, mul=1.0).mix(2).out()

chord_mix = pitched_0 + pitched_1 + pitched_2
ambient_space = Freeverb(chord_mix, size=0.9, damp=0.5, bal=0.5).mix(2).out()


Ball Bounce Noise + Some quick music theory :)

For this project I didn't want to hard code in a wav file and have that play for the ball bounce, I thought that would be kind of dull. I ended up going with random notes from the C Major Pentatonic scale.


Since I wanted these to play in random order I needed a set of notes that would sound good, no matter what order they were played in. Fundamentally a pentatonic scale with just five notes per octave. Pentatonic scales apparently are quite forgiving, meaning they sound pretty decent even when you play them in random succession.


In Pyo I just defined 3 octaves of pentatonic scales. Everything is done in Midi notes which range from 0-127 then later converted to its frequency equivalent in [Hz].

#this is based of the default C Major pentatonic so like [0,2,4,7,9]
PENTATONIC_NOTES = [36, 38, 40, 43, 45, # Octave 3
48, 50, 52, 55, 57, # Octave 4
60, 62, 64, 67, 69] # Octave 5

#this is just a scale so +12 -12 for new octave changes
OCTAVE_SHIFT = 0
PENTATONIC_NOTES = [x + OCTAVE_SHIFT for x in PENTATONIC_NOTES]

For Midi every octave is base 12 so Middle C (C4)= 60, then one octave above middle C (C5) = 72.

Since C Major Pentatonic scale = [0,2,4,7,9] - notice how every octave is just a multiple of 12 on the original C Major Pentatonic scale.


For collisions, in Pymunk whenever a collision between a ball and platform or ball and ball happened, I simply played a random note from the notes defined above.

NUM_VOICES = 8
voices = []

ATTACK = 0.08 #seconds to climb to MAX vol
DECAY = 0.4 #seconds to fall to RESTING vol
SUSTAIN = 0.1 #RESTING volume at 10% max vol
RELEASE = 3.0 #seconds for note to fade into silence
DURATION = 3.5 #how long it stays active before it closes

for i in range(NUM_VOICES):
ADSR = Adsr(attack=ATTACK, decay=DECAY, sustain=SUSTAIN, release=RELEASE, dur=DURATION, mul=0)
OSC = Sine(freq=440, mul=ADSR)

voices.append({'ADSR': ADSR, 'OSC': OSC})

#mix all the voices together
mixed_voices = Mix([v['OSC'] for v in voices], voices=2)


#add reverb
reverb = Freeverb(mixed_voices, size=0.95, damp=0.5, bal=0.5).out()

Another thing to note is the ADSR which was something very new to me. It defines the Attack, Decay, Sustain, Release, and Duration of the note played. I included a graph that shows how the note is played above. I made sure to make the note 'reverby' to give the notes a more 'hypnotizing' feel if you kind of get what I mean.

Honestly I just played around with the ADSR until I got something I liked lol

Pretty Rendering: Colors and Visual Effects

Screenshot 2026-06-01 013613.png

The final step that ties everything together is making the render actually look decent. You would think Pygame, being a engine to create games would make it easier to make things look good, but for me apparently not. I wanted to keep things simple as this was my first time using Pygame and add two things: A nice color palette and glow


Color palette

The color palette is quite simple to make in python. I simply defined a list of tuples which acted as the RGB values.

neon_ball_colors = [
(255, 42, 109),
(255, 126, 39),
(254, 213, 51),
(5, 213, 175),
(1, 190, 254),
(143, 0, 255)
]

Then whenever a ball was created - it simply picks a random color from the defined list above.


Glow

This was the weird part. Originally my plan was just to draw multiple circles with slightly larger radiuses and low opacity around the ball to simulate a glowing effect. While this was the method I used in the end, apparently Pygame doesn't support drawing circles with modified alpha values - meaning I needed to create a helper function to do this for me.

def __draw_circle_alpha(self, surface, color, center, radius, alpha=255, width=0):
r, g, b = color[:3]

diameter = radius * 2
temp_surface = pygame.Surface((diameter, diameter), pygame.SRCALPHA)

pygame.draw.circle(temp_surface, (r, g, b, alpha), (radius, radius), radius, width)

target_x = center[0] - radius
target_y = center[1] - radius
surface.blit(temp_surface, (target_x, target_y))


Once I created this function we can use it in the draw glow function

def draw_glow(self, screen, size, intensity):
for i in range(1, intensity):
self.__draw_circle_alpha(screen, self.color, self.body.position, 25 + (i * size), 10)

The size just defines the difference in radius for each glow circle and the intensity just defines how many layers of glow is drawn over the ball.

Thanks for Reading!

Well that's about it for the project - I had a blast making this, I learned a ton of stuff along the way and I hope you enjoyed reading this :)


if you want to access my python files - I've attached them below. Run main.py to test it out for yourself (there are a few parameters you might need to change like screen size)

#CONTROLS:
# 'n' to show/hide platforms
# 'c' to recalibrate color #may not be working loll i tested before and it worked but nomw idk why but i gotta submit
# 'q' to quit

It should work - though some features may crash unexpectantly lol. Didn't have time to go back and implement fixes but feel free to modify and add stuff of your own! Also apologies for any bad programming practice and any random unused functions lol - I am not a CS major just trying my best ha


In the future I was thinking there are a ton of cool stuff you could do with this like:

  1. Different color tape doing different things?
  2. Things other than balls? What about water that would be pretty neat


Also special thanks to my sister emily for helping record the demo video, she helped me out a ton!

Anyways thanks for reading!


Andrew 2026