My first design was very straightforward.
Easy, right?
Enemies in Valorant have a distinct outline to make them stick out from the environment. You can use simple color filtering for those colors to figure out where they are located on the screen.
screenshot = ImageGrab.grab()
screenshot_array = np.array(screenshot)
lower_red_bound = np.array([0, 0, 140])
upper_red_bound = np.array([100, 100, 255])
# Results in bitmap of pixels that are colored red
red_pixels = cv2.inRange(screenshot_array, lower_red_bound, upper_red_bound)
Assuming there is only one enemy on screen, the average (x,y)
coordinate of
all red pixels on screen gave me an approximation of the enemie's
"center of mass" and thus where I want to aim.
When you want to print a 3D model, you need to "slice" the model into a "GCode file" which you then execute on the 3D printer. GCode files are like Python turtle programs --- step-by-step instructions for the 3D printer.
M104 S185 # Set temperature of nozzle
G0 1 0 # Move nozzle 1u up
G0 0 1 # Move nozzle 1u right
G0 0 -1 # Move nozzle 1u down
G0 -1 0 # Move nozzle 1u left
Typically, you would load the GCode file onto an SD card and insert it into the 3D printer, but you can actually send GCode instructions over USB, one at a time.
import serial
port = "/dev/ttyUSB0"
ser = serial.Serial(port, 9600, timeout=1)
ser.write(b'G0 10 10')
ser.close()
I can use this to control the 3D printer in real time.
Now we can do all of this at 60Hz:
... but this didn't work at all. The movement was either choppy or delayed no matter how I tuned it. I realized all my problems were caused by the firmware of the 3D printer --- the program that's recieving the GCode commands. The way it handles commands did not suite my project.
When you send commands to the 3D printer while it is currently in the middle of executing a command, it will queue the new command. Once it finishes the command it's executing, it will dequeue to get its next instruction. It will not cancel the currently executing command nor is there a way to do that (to my knowledge).
Suppose you send commands that take Δt to finish once every Δt seconds. This should theoretically work fine because as soon as the current command finishes, it will receive a new command with virtually no downtime or latency. But if your command durations or command frequency are desynchronized, then you will have a terrible issue. Either:
The 3D printer will always decelerate to 0 after every command. This means that between every command, it will momentarily stop. Since I was trying to run multiple commands each second, this made it jittery. To solve this, I could run less commands per second but that lower my robot's reaction speed. I couldn't find a good balance.
If replace the 3D printer's firmware with something else, then I can make this robot work better. I decided to write my own.
Easy, right?
My 3D printer is a Monoprice Maker Select V2 which has a Melzi 2 control board.
Despite how it looks, this board is literally just an Arduino. It has an ATMEGA1284P processor which is a Sanguino microcontroller. Sanguinos are high-performance chips that are compatible with Arduino software.
Initially, there's no way to overwrite the default 3D printer firmware. I had to follow this guide to install a bootloader, which lets me upload C++ code to the Melzi using the Arduino IDE just like any other Arduino.
For this blog post, I'm using pseudocode to make thing simpler. If you want to learn about writing firmware for 3D printers in detail, read this post.
The code comes in two parts --- getting the data from USB, and then moving the motors according to the given state.
int xDisplacement, yDisplacement;
while true {
xDisplacement, yDisplacement = getDataFromUSB();
moveMotors();
}
Since we're getting rid of the 3D printer firmware, we can drop g-code entirely. I switched to a new format that's a lot easier and faster to use.
Simple: 3 bytes. The first two are 1-byte signed integers which denotes
the displacement the enemy is from the crosshair.
The third bit is 0
, and is used for alignment.
The first two bytes can't be 0
so we can't get them confused.
bool correctingError = false;
void updateError() {
if (correctingError && Serial.available()) {
byte data = Serial.read();
if (data == END_BYTE) {
correctingError = false;
}
}
if (Serial.available() >= 3) {
byte data[3];
Serial.readBytes(data, 3);
if (data[2] == END_BYTE) {
updateVars(data[0] - 128, data[1] - 128);
} else {
correctingError = true;
}
}
}
We can control each servo using a set of digital pins:
0
=clockwise, 1
=counterclockwise)For example, here's some pseudocode that will move the motor as fast as possible:
CW, CCW = 0, 1
digitalOutput(DIRECTION_PIN, CCW)
stepState = False
for _ in [0..numSteps]:
digitalOutput(STEP_PIN, stepState)
stepState = ~stepState # Invert state
The faster you can toggle the state of STEP_PIN
, the faster the stepper motor
goes. Additionally, this means our program must be in the form of a very
efficient loop. We must toggle the state regularly or else the stepper motor
will jitter.
Irrelevant rant
There's one thing I want to note about development on a Melzi board that
frustrates me dearly. Stepper motors move in units called "steps" which is
dependent on the specific design of the given motor, and is typically 30
degrees. In order to make the motor move a step, you need to alternate the
STEP_PIN. If I want to make the motor move 5 steps, I alternate the STEP_PIN 5
times. Stepper motors can also move in half, quarter, eigth and potentially up
to 256th of a step depending on the motor. Larger steps make it move faster, and
smaller steps allow more precision.
Whether the stepper driver will make the motor move in full, half, quater, etc
steps depends on what mode it is in. That can be decided by using the mode pins.
The melzi pinout doesn't have any pins to choose the mode because the drivers
are hard wired to stay on the 1/16th-step mode.
I really wanted to use full steps for large, sweeping motions and then switch to smaller steps for small aim adjustments but because they hard wired it, I just had to make do. The speed of the motor depends on how fast you can alternate its STEP_PIN, so I had to make sure performance was a priority in my code. Moving the stepper motor is typically a blocking statement so so the microcontroller has enough processing power to alternate the STEP_PIN in a stable way. I found all the libraries for Stepper motors extremely limiting because they're suited for constant acceleration.
We can state this problem in terms of error correction. The number of pixels the robot is away from the enemy is the error, and our goal is to make this error zero. When the error is zero, it means our crosshair is right on top of the enemy. PID is a very classic algorithm used in robotics and I used it all the time in my high school's FIRST robotics club, but I didn't feel this problem needed a full-blown PID algorithm. The output of PID tells me what speed I need to set the motor to quickly and smoothly move the motor to a position, but I didn't have such direct control over the speed. As shown in the previous section, all I could do is move it one step at a time.
I had to take an alternative approach. "Inverse P" is not a real control system, but it's the most natural solution to this problem under the given restrictions.
step = 0
while True:
error = get_error()
period = c * 1. / error
if step % period == 0:
moveMotorOneStep()
step += 1
The higher the error, the higher the frequency the motor would be moved one step, and thus the faster the motor will move.
This worked! But not that well...