
Electric Ripple
Interactive art through responsive mechatronics.
Dates
October 2024 – December 2024
Team
Shannon Murphy, Lera Anders, Xitong (Rocky) Yuan
Role
Electro-mechanical integration, machine vision, coding
Skills
Fabrication: 3D Printing, Laser Cutting, Power Tools, Soldering, Wire Management
Design: Arduino, JavaScript, Adobe Illustrator, Onshape
Leadership: Cross-functional Collaboration, Budget Management
Materials
- 72 MG90S Servo Motors
- 72 3D Printed Tiles
- Kinect V2 Camera
- Whiteboard Easel
- PCA9685 Servo Drivers
- Adafruit Metro Mini
- 5V 60A Power Supply
- Sooo many wires


Kinect V2 camera detects a person
Microprocessor collects body tracking data from the camera
Servos respond to your body movement, like a mirror!



Machine Vision & Image Processing
UI Display for fine-tuning depth and contrast values to reducing noise.
JavaScript code using the Processing IDE. Data is sent to Arduino through serial communication.
import KinectPV2.*;
import processing.serial.*;
// Kinect variables
KinectPV2 kinect;
PImage depthImage;
PImage bodyTrackImage;
// Grid and sliders variables
float minDepth = 500 / 304.8; // Minimum depth threshold in feet
float maxDepth = 2000 / 304.8; // Maximum depth threshold in feet
int rows = 9;
int cols = 8;
int tileWidth, tileHeight;
// Smoothing variables
int smoothingFrames = 10; // Number of frames to average
boolean[][] tileStateHistory;
int currentHistoryFrame = 0;
// Sliders
Slider minDepthSlider;
Slider maxDepthSlider;
Slider smoothingSlider;
// Serial communication
Serial myPort;
byte[] byteArray;
void setup() {
size(1024, 768); // Larger window to show more debug info
// Initialize Kinect with explicit feature enabling
kinect = new KinectPV2(this);
kinect.enableDepthImg(true);
kinect.enableColorImg(true);
kinect.enableInfraredImg(true);
kinect.enableBodyTrackImg(true);
kinect.enableSkeletonColorMap(true);
kinect.init();
// Calculate tile size
tileWidth = 512 / cols; // Kinect resolution for grid
tileHeight = 424 / rows;
// Initialize tile state history
tileStateHistory = new boolean[smoothingFrames][rows * cols];
// Initialize sliders BELOW the images
minDepthSlider = new Slider(50, 550, 200, 20, 0, 4500, 500);
maxDepthSlider = new Slider(50, 650, 200, 20, 0, 4500, 2000);
smoothingSlider = new Slider(350, 550, 200, 20, 1, 10, 3);
// Serial communication (optional, comment out if not using)
try {
String portName = Serial.list()[0]; // Use the first available port
myPort = new Serial(this, portName, 9600);
} catch (Exception e) {
println("Serial port error: " + e.getMessage());
myPort = null;
}
// Initialize byte array for serial communication
byteArray = new byte[rows];
frameRate(30);
}
void draw() {
background(50); // Dark gray background
// Update slider values
minDepth = minDepthSlider.getValue() / 304.8; // Convert minDepth to feet
maxDepth = maxDepthSlider.getValue() / 304.8; // Convert maxDepth to feet
smoothingFrames = (int) smoothingSlider.getValue();
// Resize history array if smoothing frames changed
if (tileStateHistory.length != smoothingFrames) {
tileStateHistory = new boolean[smoothingFrames][rows * cols];
}
// Get raw depth and body track data from Kinect
int[] rawDepth = kinect.getRawDepthData();
int[] rawBodyTrack = kinect.getRawBodyTrack();
bodyTrackImage = kinect.getBodyTrackImage();
depthImage = kinect.getDepthImage();
// Display debug images
if (depthImage != null) {
image(depthImage, 0, 0, 512, 424);
}
if (bodyTrackImage != null) {
image(bodyTrackImage, 512, 0, 512, 424);
}
// Attempt to display grid and send serial
if (rawDepth != null && rawBodyTrack != null) {
displayGridAndSendSerial(rawDepth, rawBodyTrack);
}
// Display sliders and text
fill(255);
textSize(20);
text("Depth Controls", 50, 520);
textSize(14);
minDepthSlider.display("Min Depth");
text(nf(minDepthSlider.getValue() / 304.8, 1, 2) + " ft", 270, 560);
maxDepthSlider.display("Max Depth");
text(nf(maxDepthSlider.getValue() / 304.8, 1, 2) + " ft", 270, 660);
smoothingSlider.display("Smoothing Frames");
text(smoothingFrames + " frames", 570, 560);
fill(255);
textSize(12);
text("Body Tracking Debug Info", 530, 500);
text("Depth Image: " + (depthImage != null), 530, 520);
text("Body Track Image: " + (bodyTrackImage != null), 530, 540);
}
void displayGridAndSendSerial(int[] rawDepth, int[] rawBodyTrack) {
// Find the most prominent body
int primaryBodyId = findPrimaryBodyId(rawBodyTrack);
// Current frame's tile states
boolean[] currentFrameTileStates = new boolean[rows * cols];
for (int row = 0; row < rows; row++) {
byte rowByte = 0; // Initialize byte for this row
for (int col = 0; col < cols; col++) {
int x1 = col * tileWidth;
int y1 = row * tileHeight;
// Get average depth for the tile
float avgDepth = getTileDepth(rawDepth, x1, y1, tileWidth, tileHeight) / 304.8; // Convert to feet
// Check body presence in this tile for the primary body
boolean bodyInTile = checkBodyInTileSingleBody(rawBodyTrack, x1, y1, tileWidth, tileHeight, primaryBodyId);
// Check if the tile is within depth range
boolean inRange = (avgDepth >= minDepth && avgDepth <= maxDepth);
// Combine with body detection
boolean currentTileState = inRange && bodyInTile;
// Store current tile state
currentFrameTileStates[row * cols + col] = currentTileState;
// Determine final state after smoothing
boolean smoothedTileState = smoothTileState(row * cols + col, currentTileState);
if (smoothedTileState) {
fill(0); // Black for smoothed tile state
rowByte |= (1 << col); // Set corresponding bit
} else {
fill(255); // White for other conditions
}
stroke(100); // Tile borders
rect(x1, y1, tileWidth, tileHeight);
}
byteArray[row] = rowByte; // Store row byte
}
// Update tile state history
tileStateHistory[currentHistoryFrame] = currentFrameTileStates;
currentHistoryFrame = (currentHistoryFrame + 1) % smoothingFrames;
// Send all bytes over serial (optional)
if (myPort != null) {
myPort.write(byteArray);
delay(50); // Small delay for serial communication stability
}
}
int findPrimaryBodyId(int[] rawBodyTrack) {
// Count body pixels for each body ID
int[] bodyPixelCounts = new int[7]; // 0-6 are valid body IDs
for (int bodyPixel : rawBodyTrack) {
if (bodyPixel >= 0 && bodyPixel <= 6) {
bodyPixelCounts[bodyPixel]++;
}
}
// Find the body ID with the most pixels
int primaryBodyId = -1;
int maxPixels = 0;
for (int i = 0; i < bodyPixelCounts.length; i++) {
if (bodyPixelCounts[i] > maxPixels) {
maxPixels = bodyPixelCounts[i];
primaryBodyId = i;
}
}
return primaryBodyId;
}
boolean checkBodyInTileSingleBody(int[] rawBodyTrack, int x1, int y1, int tileWidth, int tileHeight, int primaryBodyId) {
if (primaryBodyId == -1) return false;
for (int y = y1; y < y1 + tileHeight && y < 424; y++) {
for (int x = x1; x < x1 + tileWidth && x < 512; x++) {
int index = y * 512 + x;
if (index < rawBodyTrack.length) {
// Check if any pixel in the tile matches the primary body ID
if (rawBodyTrack[index] == primaryBodyId) {
return true;
}
}
}
}
return false;
}
float getTileDepth(int[] rawDepth, int x1, int y1, int tileWidth, int tileHeight) {
float totalDepth = 0;
int validPixels = 0;
for (int y = y1; y < y1 + tileHeight && y < 424; y++) {
for (int x = x1; x < x1 + tileWidth && x < 512; x++) {
int index = y * 512 + x;
if (index < rawDepth.length) {
int depth = rawDepth[index];
if (depth > 0) { // Only count non-zero depth values
totalDepth += depth;
validPixels++;
}
}
}
}
return validPixels > 0 ? totalDepth / validPixels : 0;
}
boolean smoothTileState(int tileIndex, boolean currentState) {
// Count how many frames in history show this tile as active
int activeFrameCount = 0;
for (int frame = 0; frame < smoothingFrames; frame++) {
if (tileStateHistory[frame][tileIndex]) {
activeFrameCount++;
}
}
// Require a majority of frames to confirm a state change
return activeFrameCount >= ceil(smoothingFrames / 2.0);
}
// Slider class definition
class Slider {
float x, y, w, h;
float minValue, maxValue, value;
boolean dragging = false;
// Constructor with 7 parameters (int x, int y, int w, int h, int minValue, int maxValue, int defaultValue)
Slider(int x, int y, int w, int h, int minValue, int maxValue, int defaultValue) {
this.x = x;
this.y = y;
this.w = w;
this.h = h;
this.minValue = minValue;
this.maxValue = maxValue;
this.value = defaultValue;
}
void display(String label) {
fill(255);
textAlign(LEFT, CENTER);
textSize(14);
text(label, x, y - 15);
stroke(150);
fill(100);
rect(x, y, w, h);
float handlePos = map(value, minValue, maxValue, x, x + w);
fill(0, 255, 0);
noStroke();
ellipse(handlePos, y + h / 2, 16, 16);
}
float getValue() {
return constrain(value, minValue, maxValue);
}
void mousePressed() {
if (mouseX >= x && mouseX <= x + w && mouseY >= y - 10 && mouseY <= y + h + 10) {
dragging = true;
updateValue();
}
}
void mouseDragged() {
if (dragging) {
updateValue();
}
}
void mouseReleased() {
dragging = false;
}
private void updateValue() {
value = map(constrain(mouseX, x, x + w), x, x + w, minValue, maxValue);
}
}
void mousePressed() {
minDepthSlider.mousePressed();
maxDepthSlider.mousePressed();
smoothingSlider.mousePressed();
}
void mouseReleased() {
minDepthSlider.mouseReleased();
maxDepthSlider.mouseReleased();
smoothingSlider.mouseReleased();
}
void mouseDragged() {
minDepthSlider.mouseDragged();
maxDepthSlider.mouseDragged();
smoothingSlider.mouseDragged();
}Motor Control
Circuit layout including 72 servos, 5 PWM drivers, and an external power supply.

Arduino Code unpacks data from Processing, and moves servos accordingly.
#include <Wire.h>
#include <Adafruit_PWMServoDriver.h>
// Create 5 servo driver objects with different I2C addresses
Adafruit_PWMServoDriver pwm1 = Adafruit_PWMServoDriver(0x40); // First driver
Adafruit_PWMServoDriver pwm2 = Adafruit_PWMServoDriver(0x41); // Second driver
Adafruit_PWMServoDriver pwm3 = Adafruit_PWMServoDriver(0x43); // Third driver
Adafruit_PWMServoDriver pwm4 = Adafruit_PWMServoDriver(0x48); // Fourth driver
Adafruit_PWMServoDriver pwm5 = Adafruit_PWMServoDriver(0x44); // Fifth driver
#define SERVOMIN 220 // Minimum pulse length count for 0 degrees
#define TOTAL_SERVOS 72 // Total number of servos
#define BUTTON_PIN 2 // Pin for button input
const int servo_range = 2*22; // Range of movement for active position
// 9x8 array to store servo offset values (default 0)
int servo_offset[9][8] = {
{ 0, 0, 0, 20, 20, 20, 10, -20 },
{ 10, -15, 15, 20, 25, 10, 10, 20 },
{ 0, 15, 0, 0, 10, 15, 20, 0 },
{ 35, 0, 15, 0, 0, 0, 10, -5 },
{ 30, 30, 33, 60, 17, 15, -10, 10 }, //3RD BROKEN
{ 10, 20, 0, 0, 5, 55, 10, 10 },
{ 15, 25, 0, 25, -5, -5, 15, 0 }, //# 2 slow??
{ 25, 20, 10, 0, -5, 5, 15, 25 },
{ 15, 25, 15, 15, -5, 2, 0, 15 }
};
// Buffer for incoming and previous states
byte incomingData[9] = { 0 };
byte previousData[9] = { 0 };
// Setup function
void setup() {
Serial.begin(9600);
pinMode(BUTTON_PIN, INPUT_PULLUP); // Button for resetting to servo_start positions
// Initialize all servo drivers
pwm1.begin();
pwm2.begin();
pwm3.begin();
pwm4.begin();
pwm5.begin();
// Set PWM frequency for all drivers
pwm1.setPWMFreq(50);
pwm2.setPWMFreq(50);
pwm3.setPWMFreq(50);
pwm4.setPWMFreq(50);
pwm5.setPWMFreq(50);
// Initialize all servos to their servo_start positions
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 8; j++) {
int servoNum = i * 8 + j; // Servo number in 1D
int servo_start = SERVOMIN + servo_offset[i][j]; // Calculate starting position
setServoPosition(servoNum, servo_start); // Set servo to starting position
delay(150); // Small delay for smooth initialization
}
}
}
// Set servo position based on its number and desired PWM value
void setServoPosition(int servoNum, int pwmValue) {
Adafruit_PWMServoDriver* driver;
int pin;
if (servoNum < 16) {
driver = &pwm1;
pin = servoNum;
} else if (servoNum < 32) {
driver = &pwm2;
pin = servoNum - 16;
} else if (servoNum < 48) {
driver = &pwm3;
pin = servoNum - 32;
} else if (servoNum < 64) {
driver = &pwm4;
pin = servoNum - 48;
} else {
driver = &pwm5;
pin = servoNum - 64;
}
driver->setPWM(pin, 0, pwmValue);
}
// Loop function
void loop() {
// Check if the button is pressed
if (digitalRead(BUTTON_PIN) == HIGH) {
Serial.println("Button is pressed");
// Reset all servos to their `servo_start` positions
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 8; j++) {
int servoNum = i * 8 + j;
int servo_start = SERVOMIN + servo_offset[i][j];
setServoPosition(servoNum, servo_start);
}
}
delay(2000); // Debounce delay + extra
}
// Handle incoming serial data
if (Serial.available() >= 9) { // Wait for full 9 bytes
Serial.readBytes(incomingData, 9);
// Update servo positions if data has changed
for (int row = 0; row < 9; row++) {
if (incomingData[row] != previousData[row]) {
for (int col = 0; col < 8; col++) {
int servoIndex = row * 8 + col;
bool newState = incomingData[row] & (1 << col);
bool oldState = previousData[row] & (1 << col);
if (newState != oldState) {
int servo_start = SERVOMIN + servo_offset[row][col];
int targetPosition = newState ? servo_start + servo_range : servo_start;
setServoPosition(servoIndex, targetPosition);
delay(5); // Small delay for smooth servo updates
}
}
previousData[row] = incomingData[row]; // Update previous state
}
}
}
// Clear serial buffer if too much data accumulates
while (Serial.available() > 9) {
Serial.read();
}
}











