Tank Bot - Raspberry Pi + Video + OpenCV + PS5 Controller

robotics raspberry pi

The finished Tank Bot is equipped with a Raspberry Pi 4, a camera, and other sensors.

 

Background

When building the Hexapod Spider Bot and dealing with the complexities involved with handling legs, I began to wonder if maybe I should have just started with a tank instead. Perhaps it would be a better ROI since the tank will be fast, stable, and easier to code for than 6 jointed legs. With a reliable vehicle base that can navigate most terrain, we should then be free to focus on the "brain" of the robot or other features.

When building the Hexapod Spider Bot and dealing with the complexities involved in managing legs, I began to wonder if perhaps I should have started with a tank instead. The tank would be fast, stable, and easier to program than six jointed legs. With a reliable vehicle base that can navigate most terrains, we could then focus on the "brain" of the robot or other features.

 

Notable Components

  1. Tank Chassis Selection
  2. L298N Motor Driver
  3. Remote Control via RF24
  4. Tank Client/Server via REST API
  5. Portable Power Supply
  6. Streaming Video + OpenCV
  7. Extensible Sensor/Peripherals

 

1. Selecting the Tank Chassis

While browsing Amazon one day, I came across this chassis. It's made up of an extensible metal base and high-quality track treads with suspension. Assembling the chassis was straightforward.


Chassis along with other components

Building wheel-mounts and suspension

Attaching wheels

Attaching the base platform


Attaching track treads


2. L298N Motor Driver

The first significant challenge I faced was determining how to power and control the DC motors from a Raspberry Pi. I've found that the KEYESTUDIO GPIO Breakout Board is an excellent choice for easy communication with peripherals. Next, I decided to use a L298N Motor Driver to interface with the DC motor. The L298N chip simplifies the implementation of variable speed and reverse functions, while also protecting the motors and electronics.


A working L298N prototype!

Mounted L298N chips

L298N Motor Driver

DC Motor

 

3. Remote Control via RF24

The Hexapod Spider Bot uses two NRF24L01+ wireless transceivers for communication. I initially attempted to re-implement the RF24 code in Python but encountered issues that I couldn't seem to resolve. Subsequently, I decided to try out C++, with which I surprisingly had success. The (very) rough code can be found here.

Here are some videos demonstrations:

4. Tank Client/Server via REST API

After working with RF24 a bit, I started to think that there could be an easier way. The plan is as follows:

  • Build a core Tank library to simplify interactions with the Tank.
  • Construct an HTTP web server using Flask to run on the Raspberry Pi.
  • Use Python due to ease-of-use and the plethora of libraries available for ML, NLP, GPIO, OpenCV, Flask, etc.
  • Develop multiple Tank clients: CLI, PostMan, React App, PyGame + PS5 Controller.
tank.py

Primary Tank code

import RPi.GPIO as GPIO
from enum import Enum

R_PIN_ENABLE_A = 25
R_PIN_IN1 = 24
R_PIN_IN2 = 23

L_PIN_ENABLE_A = 16
L_PIN_IN1 = 20
L_PIN_IN2 = 26

GPIO.setmode(GPIO.BCM)  # use BCM numbers


class Direction(Enum):
    STOP = 0
    FORWARD = 1
    REVERSE = 2


class Track:
    def __init__(self, pin_enableA: int, pin_in1: int, pin_in2: int, is_inverted: bool = False):
        print("Init Track, enA: " + str(pin_enableA) + ", in1: " + str(pin_in1) + ", in2: " + str(pin_in2))
        self.pin_enableA = pin_enableA
        self.pin_in1 = pin_in1
        self.pin_in2 = pin_in2
        self.pwmMax: int = 100
        self.pwmStart: int = 100
        self.speed: int = 100  # not used
        self.direction = Direction.STOP
        self.is_inverted = is_inverted

        GPIO.setup(pin_in1, GPIO.OUT)
        GPIO.setup(pin_in2, GPIO.OUT)
        GPIO.setup(pin_enableA, GPIO.OUT)

        GPIO.setup(pin_in1, GPIO.LOW)
        GPIO.setup(pin_in2, GPIO.LOW)

        self.pwm = GPIO.PWM(pin_enableA, self.pwmMax)
        self.pwm.start(self.pwmStart)

    def forward(self):
        print("track forward")
        self.direction = Direction.FORWARD
        if not self.is_inverted:
            GPIO.output(self.pin_in1, True)
            GPIO.output(self.pin_in2, False)
            self.set_speed(self.speed)
            # self.pwm.ChangeDutyCycle(100)
        else:
            GPIO.output(self.pin_in1, False)
            GPIO.output(self.pin_in2, True)
            self.set_speed(self.speed)
            # self.pwm.ChangeDutyCycle(0)

    def reverse(self):
        print("track reverse")
        self.direction = Direction.REVERSE
        if not self.is_inverted:
            GPIO.output(self.pin_in1, False)
            GPIO.output(self.pin_in2, True)
            self.set_speed(self.speed)
            # self.pwm.ChangeDutyCycle(0)
        else:
            GPIO.output(self.pin_in1, True)
            GPIO.output(self.pin_in2, False)
            self.set_speed(self.speed)
            # self.pwm.ChangeDutyCycle(100)

    def stop(self):
        print("track stop")
        self.direction = Direction.STOP
        GPIO.output(self.pin_in1, False)
        GPIO.output(self.pin_in2, False)
        # keep speed as-is, and change duty cycle so that resuming movement will use last speed.
        self.pwm.ChangeDutyCycle(0)

    def set_speed(self, speed: int):
        self.speed = speed
        if self.speed >= 100:
            self.speed = 100
        elif self.speed < 0:
            self.speed = 0

        print("speed: " + str(self.speed))

        # if motor is not running, only set the internal speed. speed will be passed to motor when tank moves.
        if self.direction == Direction.STOP:
            print("tank stopped, not changing motor speed")
            return

        # # in the case speed is 0, then just call our helper function to stop the track
        # if self.speed == 0:
        #     self.direction = Direction.STOP
        #     self.stop()

        if self.direction == Direction.FORWARD:
            if not self.is_inverted:
                self.pwm.ChangeDutyCycle(self.speed)
            else:
                self.pwm.ChangeDutyCycle(100 - self.speed)
        elif self.direction == Direction.REVERSE:
            if not self.is_inverted:
                self.pwm.ChangeDutyCycle(100 - self.speed)
            else:
                self.pwm.ChangeDutyCycle(self.speed)

    def speed_up(self):
        print("speed++")
        self.set_speed(self.speed + 10)

    def speed_down(self):
        print("speed--")
        self.set_speed(self.speed - 10)


class Tank:
    def __init__(self):
        self.left_track = Track(L_PIN_ENABLE_A, L_PIN_IN1, L_PIN_IN2, is_inverted=True)
        self.right_track = Track(R_PIN_ENABLE_A, R_PIN_IN1, R_PIN_IN2, is_inverted=True)

    def status(self):
        return {
            'leftTrack': {
                'speed': self.left_track.speed
            },
            'rightTrack': {
                'speed': self.right_track.speed
            }
        }

    def cleanup(self):
        GPIO.cleanup()

    def left_track_forward(self):
        self.left_track.forward()

    def left_track_reverse(self):
        self.left_track.reverse()

    def left_track_stop(self):
        self.left_track.stop()

    def right_track_forward(self):
        self.right_track.forward()

    def right_track_reverse(self):
        self.right_track.reverse()

    def right_track_stop(self):
        self.right_track.stop()

    def forward(self):
        print("forward")
        self.left_track.forward()
        self.right_track.forward()

    def reverse(self):
        print("reverse")
        self.left_track.reverse()
        self.right_track.reverse()

    def stop(self):
        print("stop")
        self.left_track.stop()
        self.right_track.stop()

    def turn_left(self):
        print("turn left")
        self.left_track.stop()
        self.right_track.forward()

    def turn_right(self):
        print("turn right")
        self.left_track.forward()
        self.right_track.stop()

    def rotate_clockwise(self):
        print("rotate clockwise")
        self.left_track.forward()
        self.right_track.reverse()

    def rotate_counterclockwise(self):
        print("rotate counterclockwise")
        self.left_track.reverse()
        self.right_track.forward()

    def right_track_speed_up(self):
        print("right track speed++")
        self.right_track.speed_up()

    def right_track_speed_down(self):
        print("right track speed--")
        self.right_track.speed_down()

    def left_track_speed_up(self):
        print("left track speed++")
        self.left_track.speed_up()

    def left_track_speed_down(self):
        print("left track speed--")
        self.left_track.speed_down()

    def speed_up(self):
        print("speed++")
        self.left_track.speed_up()
        self.right_track.speed_up()
        return self.status()

    def speed_down(self):
        print("speed--")
        self.left_track.speed_down()
        self.right_track.speed_down()
        return self.status()

    def set_speed(self, speed: int):
        print("set speed: " + str(speed))
        self.left_track.set_speed(speed)
        self.right_track.set_speed(speed)
        return self.status()

 

tank_server.py

A lightweight http server for interfacing with a Tank.

from flask import Flask
from flask_cors import CORS
from .tank import Tank

tank = Tank()
tank.stop()

app = Flask(__name__)
CORS(app)

@app.route('/tank/status', methods=['GET'])
def tank_status():
    return tank.status()

@app.route('/tank/forward', methods=['POST'])
def tank_forward():
    tank.forward()
    return ""

@app.route('/tank/reverse', methods=['POST'])
def tank_reverse():
    tank.reverse()
    return ""

@app.route('/tank/stop', methods=['POST'])
def tank_stop():
    tank.stop()
    return ""

@app.route('/tank/turn-left', methods=['POST'])
def tank_turn_left():
    tank.turn_left()
    return ""

@app.route('/tank/turn-right', methods=['POST'])
def tank_turn_right():
    tank.turn_right()
    return ""

@app.route('/tank/left-track/forward', methods=['POST'])
def tank_left_track_forward():
    tank.left_track_forward()
    return ""

@app.route('/tank/left-track/reverse', methods=['POST'])
def tank_left_track_reverse():
    tank.left_track_reverse()
    return ""

@app.route('/tank/left-track/stop', methods=['POST'])
def tank_left_track_stop():
    tank.left_track_stop()
    return ""

@app.route('/tank/right-track/forward', methods=['POST'])
def tank_right_track_forward():
    tank.right_track_forward()
    return ""

@app.route('/tank/right-track/reverse', methods=['POST'])
def tank_right_track_reverse():
    tank.right_track_reverse()
    return ""

@app.route('/tank/right-track/stop', methods=['POST'])
def tank_right_track_stop():
    tank.right_track_stop()
    return ""

@app.route('/tank/clockwise', methods=['POST'])
def tank_clockwise():
    tank.rotate_clockwise()
    return ""

@app.route('/tank/counter-clockwise', methods=['POST'])
def tank_rotate_counterclockwise():
    tank.rotate_counterclockwise()
    return ""

@app.route('/tank/speed-up', methods=['POST'])
def tank_speed_up():
    return tank.speed_up()

@app.route('/tank/speed-down', methods=['POST'])
def tank_speed_down():
    return tank.speed_down()

@app.route('/tank/speed/<speed>', methods=['POST'])
def tank_set_speed(speed: str):
    return tank.set_speed(int(speed))

 

Starting the Tank Server

After SSHing into the Raspberry Pi, run the following commands to start the Flask Server.

export FLASK_APP=tank_server
flask run -h 192.168.4.76 -p 8080

 

PyGame Client + PS5 Controller

This ended up being my favorite implementation because it was straightforward to set up and worked seamlessly. The left and right joysticks controlled the left and right tracks respectively. Later in this post, we'll use PyGame again to render the video stream from the front-mounted camera.

tank_client_ps5_controller.py
from enum import Enum

import pygame
import requests
import os
os.environ["SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS"] = "1"

pygame.init()
pygame.event.set_grab(True)

API_URL = "http://spider.local:8080{}"

class Direction(Enum):
    FORWARD = 1
    NEUTRAL = 2
    REVERSE = 3


class TankClientController:

    def __init__(self):
        self.left_track_direction = Direction.NEUTRAL
        self.right_track_direction = Direction.NEUTRAL

    def start(self):
        requests.post(API_URL.format("/tank/stop"))
        running = True
        while running:
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    running = False

            joystick_count = pygame.joystick.get_count()
            if joystick_count == 0:
                print("No joysticks connected")
            else:
                joystick = pygame.joystick.Joystick(0)
                joystick.init()

                left_joystick_x = joystick.get_axis(0)
                left_joystick_y = joystick.get_axis(1)

                right_joystick_x = joystick.get_axis(2)
                right_joystick_y = joystick.get_axis(3)

                # print("L({}, {}), R({}, {})".format(left_joystick_x, left_joystick_y, right_joystick_x, right_joystick_y))

                if left_joystick_y < -0.9:
                    if self.left_track_direction != Direction.FORWARD:
                        self.left_track_direction = Direction.FORWARD
                        print("call /tank/left-track/forward")
                        requests.post(API_URL.format("/tank/left-track/forward"))

                elif left_joystick_y > 0.9:
                    if self.left_track_direction != Direction.REVERSE:
                        self.left_track_direction = Direction.REVERSE
                        print("call /tank/left-track/reverse")
                        requests.post(API_URL.format("/tank/left-track/reverse"))
                else:
                    if self.left_track_direction != Direction.NEUTRAL:
                        self.left_track_direction = Direction.NEUTRAL
                        print("call /tank/left-track/stop")
                        requests.post(API_URL.format("/tank/left-track/stop"))

                if right_joystick_y < -0.9:
                    if self.right_track_direction != Direction.FORWARD:
                        self.right_track_direction = Direction.FORWARD
                        print("call /tank/right-track/forward")
                        requests.post(API_URL.format("/tank/right-track/forward"))

                elif right_joystick_y > 0.9:
                    if self.right_track_direction != Direction.REVERSE:
                        self.right_track_direction = Direction.REVERSE
                        print("call /tank/right-track/reverse")
                        requests.post(API_URL.format("/tank/right-track/reverse"))
                else:
                    if self.right_track_direction != Direction.NEUTRAL:
                        self.right_track_direction = Direction.NEUTRAL
                        print("call /tank/right-track/stop")
                        requests.post(API_URL.format("/tank/right-track/stop"))

        pygame.quit()


tank_client_controller = TankClientController()
tank_client_controller.start()

 

React Web

Although I found the PyGame + PS5 controller to be my favorite Tank Client, I initially thought it would be fun to construct a React/Typescript app to interface with the Tank Server.

The code can be found here.

SSH + Tank CLI

You can SSH into the Raspberry Pi and run tank_cli.py to control the tank via CLI.

 

5. Portable Power Supply

The two DC motors and two L298N motor drivers are powered by two 3600mAh Flat Top 3.7V 30A Rechargeable Batteries. These are charged with a Universal Smart Battery Charger 4 Bay for Rechargeable Batteries with LCD Display.

For the Raspberry Pi, I have been using a USB-C Battery Pack rated for the Raspberry Pi 4, with a capacity of 10,000mAh and an output of 5V 2.4A. I have been using this setup for over half a year now with no complaints.

TODO: Initially, I installed a PiJuice HAT but encountered issues and put it on hold. I would like to come back and finish the setup.

 

6. Video Streaming + OpenCV

Handling streaming video with OpenCV is very similar to the approach we used with the Hexapod Spider Bot.

Video Streaming over TCP

To stream video from the Raspberry Pi to a client computer, I used libcamera-vid and ffplay/vlc. For my video client, I used either my primary laptop or a Raspberry Pi 3 with a monitor.

Server (spider.local)

libcamera-vid -t 0  -q 100 --framerate 3 -n --codec mjpeg --inline --listen -o tcp://192.168.4.76:8888 -v

or with lower quality and a higher framerate.

libcamera-vid -t 0  -q 50 --framerate 10 -n --codec mjpeg --inline --listen -o tcp://192.168.4.76:8888 -v 

Client (VLC -> Open Network)

tcp/mjpeg://192.168.4.76:8888

Client using FFPlay

ffplay -probesize 32 -analyzeduration 0 -fflags nobuffer -fflags flush_packets -flags low_delay -framerate 30 -framedrop tcp://192.168.4.76:8888

Notes:

  • Occasionally, I began to experience video lag after a while. Reducing the quality and framerate seemed to alleviate the problem, but this issue occurred rather frequently.
  • Another option that proved successful was setting up the Pi for a Virtual Desktop and using VNC Viewer, as detailed below.
  • Using libcamera-vid and ffplay/vlc allows you to stream the webcam data directly to clients, without any need for video processing.

 

Video Streaming via PyGame + OpenCV

Below is the code for using PyGame to read the video stream and display it on the screen. This is where you can process the video data. For instance, you can perform face detection with OpenCV as we did in the Hexapod Spider Bot post.

import pygame
import cv2
import numpy as np

pygame.init()

size = (640, 480)
screen = pygame.display.set_mode(size)
pygame.display.set_caption("Tank Camera")
cap = cv2.VideoCapture('tcp://192.168.4.76:8888')
# cap = cv2.VideoCapture("rtsp://spider.local:8081/")

class TankClientCamera:

    def __init__(self):
        pass

    def start(self):
        running = True
        while running:
            print("running")
            ret, frame = cap.read()
            if ret:
                print("capture success")
                frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
                frame = np.rot90(frame)
                frame = pygame.surfarray.make_surface(frame)
                screen.blit(frame, (0, 0))
                pygame.display.update()
            else:
                print("Image is null")

        cap.release()
        pygame.quit()


tank_client_camera = TankClientCamera()
tank_client_camera.start()

Demonstration of face detection.

 

7. Extensible Sensor/Peripherals


This image provides a top view of our GPIO board.

Components:

  • An RF24 chip responsible for RF control
  • A pair of L298N Motor Drivers for precise motor control
  • A Flashlight for night adventures
  • A Buzzer for communication using Morse Code
  • An Infrared (IR) Detector for motion detection

The GPIO board interfaces seamlessly with the Raspberry Pi through a "40-pin GPIO T-Shaped Adapter". This allows for the installation of other devices, such as an Astro Pi HAT, for added functionality.

 

Hardware Shopping List

 

Videos




And one final bonus music video "Robots Dancing" with music by Cyriak.
More