Atishay LalgudiAllen MikhailovEvelyn HardyJoshua Aviles
Published

ELEC 424 Goofy Goobers Self-Driving Car

An autonomous RC car on Raspberry Pi 5 that keeps its lane with OpenCV, controls speed via a kernel encoder driver, and stops on red.

IntermediateShowcase (no instructions)20 hours37
ELEC 424 Goofy Goobers Self-Driving Car

Things used in this project

Hardware components

Raspberry Pi 5
Raspberry Pi 5
×1
Webcam, Logitech® HD Pro
Webcam, Logitech® HD Pro
×1
Optical speed encoder
×1
Portable Charger
×1

Software apps and online services

OpenCV
OpenCV

Story

Read more

Code

encoder_overlay.dts

C/C++
/dts-v1/;
/plugin/;

/ {
    fragment@0 {
        target-path ="/";
        __overlay__ {
            optical_encoder: encoder_device {
                compatible = "elec424,optical-encoder";
                gpios = <&gpio 4 0>;
		status = "okay";
            };
        };
    };

};

encoder_driver.c

C/C++
/*
 * encoder_driver.c  Optical encoder driver for ELEC 424 Final Project
 *
 * Modified from the ELEC 424 Project 2 button driver.
 * Uses the kernel gpiod API (required by assignment) instead of the
 * deprecated gpio_request/gpio_to_irq approach.
 *
 * What this driver does:
 *   - Binds to the "elec424,optical-encoder" device tree node
 *   - Requests the GPIO declared in that node and maps it to an IRQ
 *   - On each rising edge (encoder hole passes sensor), records the
 *     elapsed time since the previous pulse
 *   - Rejects intervals < MIN_PULSE_NS to debounce mechanical noise
 *   - Calculates wheel RPM and exports it as a module parameter
 *     readable from user-space via:
 *       /sys/module/encoder_driver/parameters/speed_rpm
 *
 * Build: run  make  in this directory (see Makefile)
 * Load:  sudo insmod encoder_driver.ko
 * Read:  cat /sys/module/encoder_driver/parameters/speed_rpm
 */

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/platform_device.h>
#include <linux/gpio/consumer.h>   /* gpiod_get, gpiod_to_irq */
#include <linux/interrupt.h>
#include <linux/ktime.h>
#include <linux/atomic.h>
#include <linux/of.h>              /* of_device_id, MODULE_DEVICE_TABLE */

#define DRIVER_NAME      "optical-encoder"

/* Number of transparent slots in the encoder wheel.
 * Adjust to match the physical encoder provided in PCF3. */
#define ENCODER_HOLES    20

/* Minimum valid inter-pulse interval (5 ms).
 * Pulses arriving faster than this are treated as switch bounce / EMI noise. */
#define MIN_PULSE_NS     (5ULL * 1000000ULL)

/* If no pulse is seen for this long, report speed = 0 (car stopped). */
#define STALE_NS         (500ULL * 1000000ULL)

MODULE_LICENSE("GPL");
MODULE_AUTHOR("ELEC424 Team");
MODULE_DESCRIPTION("Optical encoder driver  measures RC car wheel speed");
MODULE_VERSION("1.0");

/* Module parameters  readable from sysfs by the Python lane-keeping script */
static int speed_rpm         = 0;
static int pulse_interval_ms = 0;
module_param(speed_rpm,         int, S_IRUGO);
module_param(pulse_interval_ms, int, S_IRUGO);
MODULE_PARM_DESC(speed_rpm,         "Current wheel speed in RPM");
MODULE_PARM_DESC(pulse_interval_ms, "Time between encoder pulses in ms");

/* GPIO descriptor obtained from the device tree node */
static struct gpio_desc *encoder_gpiod;

/* Timestamp of the most recent valid pulse (updated in IRQ context) */
static ktime_t last_pulse_ktime;

/*  Interrupt Handler  */

static irqreturn_t encoder_irq_handler(int irq, void *dev_id)
{
    ktime_t now      = ktime_get();
    u64     interval_ns;

    /* Compute elapsed nanoseconds since last pulse */
    interval_ns = (u64)ktime_to_ns(ktime_sub(now, last_pulse_ktime));

    /* Reject glitches / switch-bounce below debounce threshold */
    if (interval_ns < MIN_PULSE_NS)
        return IRQ_HANDLED;

    /* Update module parameters visible to user-space */
    pulse_interval_ms = (int)(interval_ns / 1000000ULL);

    /* RPM = 60 s/min * 1e9 ns/s / (interval_ns * holes_per_rev) */
    speed_rpm = (int)(60ULL * 1000000000ULL / (interval_ns * (u64)ENCODER_HOLES));

    last_pulse_ktime = now;
    return IRQ_HANDLED;
}

/*  Platform Driver Probe / Remove  */

static int encoder_probe(struct platform_device *pdev)
{
    int irq, ret;

    /* Retrieve the GPIO descriptor from the device tree "gpios" property */
    encoder_gpiod = devm_gpiod_get(&pdev->dev, NULL, GPIOD_IN);
    if (IS_ERR(encoder_gpiod)) {
        dev_err(&pdev->dev, "Failed to acquire encoder GPIO: %ld\n",
                PTR_ERR(encoder_gpiod));
        return PTR_ERR(encoder_gpiod);
    }

    /* Translate GPIO to an IRQ number */
    irq = gpiod_to_irq(encoder_gpiod);
    if (irq < 0) {
        dev_err(&pdev->dev, "gpiod_to_irq failed: %d\n", irq);
        return irq;
    }

    /* Request the IRQ; fire on rising edge (encoder hole unblocks IR beam) */
    ret = devm_request_irq(&pdev->dev, irq, encoder_irq_handler,
                           IRQF_TRIGGER_RISING, DRIVER_NAME, NULL);
    if (ret) {
        dev_err(&pdev->dev, "devm_request_irq failed for IRQ %d: %d\n", irq, ret);
        return ret;
    }

    /* Initialise timestamp so the first measured interval is plausible */
    last_pulse_ktime = ktime_get();

    dev_info(&pdev->dev, "Optical encoder driver loaded; IRQ %d\n", irq);
    return 0;
}

static void encoder_remove(struct platform_device *pdev)
{
    /* devm resources (GPIO + IRQ) are freed automatically by devres */
    speed_rpm         = 0;
    pulse_interval_ms = 0;
    dev_info(&pdev->dev, "Optical encoder driver removed\n");
}

/*  Device-Tree Match Table  */

static const struct of_device_id encoder_of_match[] = {
    { .compatible = "elec424,optical-encoder" },
    { }   /* sentinel */
};
MODULE_DEVICE_TABLE(of, encoder_of_match);

/*  Platform Driver Registration  */

static struct platform_driver encoder_platform_driver = {
    .probe  = encoder_probe,
    .remove = encoder_remove,
    .driver = {
        .name           = DRIVER_NAME,
        .of_match_table = encoder_of_match,
    },
};

/* module_platform_driver() expands to module_init / module_exit boilerplate */
module_platform_driver(encoder_platform_driver);

run.py

Python
# COMP 424 ELEC 424 - Final Project
# Inspired by: User raja_961, "Autonomous Lane-Keeping Car Using Raspberry Pi and OpenCV"
# Instructables. URL: https://www.instructables.com/Autonomous-Lane-Keeping-Car-Using-Raspberry-Pi-and/
# Built for Raspberry Pi 5 using gpiozero for hardware PWM.

import cv2
import numpy as np
import time
import os
import csv
import threading
import socketserver
import http.server
from gpiozero import Servo

ESC_PIN = 18
SERVO_PIN = 19

CAMERA_DEVICE = "/dev/video0"
ENCODER_PATH = "/sys/module/encoder_driver/parameters/speed_rpm"

print("Initializing ESC on GPIO4 and Servo on GPIO6...")

esc = Servo(
	ESC_PIN,
	initial_value=0,
	min_pulse_width=0.001,
	max_pulse_width=0.002,
	frame_width=0.02
)

servo = Servo(
	SERVO_PIN,
	initial_value=0,
	min_pulse_width=0.001,
	max_pulse_width=0.002,
	frame_width=0.02
)

def set_esc(value):
	esc.value = max(-1.0, min(1.0, value))

def set_servo(value):
	servo.value = max(-1.0, min(1.0, value))

def neutral():
	set_esc(0)
	set_servo(0)

# PD controller for steering
MAX_THROTTLE_CHANGE = 0.0005

Kp_steer = 0.025
Kd_steer = 0.005
prev_steer_error = 0

# Speed controller
Kp_speed = 0.001
target_speed = 300.0
current_esc_throttle = 0.20

# State machine
state = "DRIVING"
stop_count = 0
cooldown_end_time = 0

csv_file = open("run_data.csv", mode="w", newline="")
csv_writer = csv.writer(csv_file)
csv_writer.writerow([
	"Frame",
	"Error",
	"P_Response",
	"D_Response",
	"Steer_Duty",
	"Speed_Duty",
	"Measured_Speed"
])

# Outputs rpm
def read_encoder_speed():
	try:
		# Reading value from driver
		with open(ENCODER_PATH, "r") as f:
			val = float(f.read().strip())
			if val > 5000:
				return 0.0
			return val
	except Exception as e:
		return 0.0

cap = cv2.VideoCapture(CAMERA_DEVICE, cv2.CAP_V4L2)

if not cap.isOpened():
	print("ERROR: Could not open", CAMERA_DEVICE)
	neutral()
	csv_file.close()
	exit(1)

print("Camera opened successfully:", CAMERA_DEVICE)

cap.set(cv2.CAP_PROP_FRAME_WIDTH, 160)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 120)

latest_frame = None
frame_lock = threading.Lock()

class StreamHandler(http.server.BaseHTTPRequestHandler):
	def do_GET(self):
		self.send_response(200)
		self.send_header('Content-type', 'multipart/x-mixed-replace; boundary=frame')
		self.end_headers()
		while True:
			with frame_lock:
				if latest_frame is None:
					continue
				_, jpg = cv2.imencode('.jpg', latest_frame)
			self.wfile.write(b'--frame\r\n')
			self.send_header('Content-type', 'image/jpeg')
			self.end_headers()
			self.wfile.write(jpg.tobytes())
			self.wfile.write(b'\r\n')
			time.sleep(0.05)

	def log_message(self, format, *args):
		pass

server = socketserver.TCPServer(('', 8080), StreamHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()

stop_cd = 0
def process_frame(frame, frame_num):
	global prev_steer_error
	global state
	global stop_count
	global cooldown_end_time
	global current_esc_throttle
	global latest_frame
	global stop_cd

	current_time = time.time()

	# Red stop box detection every 3 frames
	if frame_num % 3 == 0:
		hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)

		# Box bounds
		lower_red1 = np.array([0, 100, 100])
		upper_red1 = np.array([10, 255, 255])
		lower_red2 = np.array([160, 100, 100])
		upper_red2 = np.array([180, 255, 255])

		red_mask = cv2.inRange(hsv, lower_red1, upper_red1) | \
				   cv2.inRange(hsv, lower_red2, upper_red2)

		contours, _ = cv2.findContours(
			red_mask,
			cv2.RETR_TREE,
			cv2.CHAIN_APPROX_SIMPLE
		)

		red_detected = False

		for cnt in contours:
			if cv2.contourArea(cnt) > 1000:
				red_detected = True
				break
		
		# The Camera is a bit too high so we delay 
		# our stop a bit so it doesnt stop too early
		if state == "DRIVING" and red_detected and current_time > cooldown_end_time:
			stop_count += 1
			# start stop countdown
			stop_cd = 10
			state = "STOP_CD"
			print("Red Box", stop_count, "Detected!")

		# Waiting for stop dountdown to end so we can actually stop
		if state == "STOP_CD":
			stop_cd -= 1
			if stop_cd <= 0:
				neutral()

				if stop_count == 1:
					state = "STOP1"
					cooldown_end_time = current_time + 3.0
				else:
					state = "STOP2"

	# First Stop Sign
	if state == "STOP1":
		neutral()
		if current_time > cooldown_end_time:
			print("Resuming driving...")
			state = "DRIVING"
			cooldown_end_time = current_time + 4.0
		with frame_lock:
			latest_frame = frame.copy()
		return frame

	# Second stop sign
	if state == "STOP2":
		neutral()
		with frame_lock:
			latest_frame = frame.copy()
		return frame

	# Lane keeping
	gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
	blur = cv2.GaussianBlur(gray, (5, 5), 0)
	edges = cv2.Canny(blur, 50, 150)

	h, w = edges.shape
	roi = np.zeros_like(edges)

	cv2.fillPoly(
		roi,
		[np.array([[(0, h), (w, h), (w, int(h * 0.7)), (0, int(h * 0.7))]])],
		255
	)

	cropped_edges = cv2.bitwise_and(edges, roi)

	lines = cv2.HoughLinesP(
		cropped_edges,
		1,
		np.pi / 180,
		20,
		minLineLength=15,
		maxLineGap=10
	)

	road_center = w // 2

	if lines is not None:
		left_xs = []
		right_xs = []

		for line in lines:
			x1, y1, x2, y2 = line[0]

			if x1 == x2:
				continue

			slope = (y2 - y1) / (x2 - x1)

			if slope < -0.3:
				left_xs.extend([x1, x2])
			elif slope > 0.3:
				right_xs.extend([x1, x2])

		lx = sum(left_xs) // len(left_xs) if left_xs else 0
		rx = sum(right_xs) // len(right_xs) if right_xs else w

		road_center = (lx + rx) // 2
	
	# Steering PD
	error = road_center - (w // 2)
	derivative = error - prev_steer_error

	p_response = Kp_steer * error
	d_response = Kd_steer * derivative

	steering_val = p_response + d_response
	prev_steer_error = error

	set_servo(steering_val)

	measured_speed = read_encoder_speed()
	speed_error = target_speed - measured_speed


	# Throttle PD
	new_throttle = current_esc_throttle + Kp_speed * speed_error
	new_throttle = max(0.20, min(0.22, new_throttle))
	diff = new_throttle - current_esc_throttle
	diff = max(-MAX_THROTTLE_CHANGE, min(MAX_THROTTLE_CHANGE, diff))
	current_esc_throttle += diff

	set_esc(max(current_esc_throttle, 0.05))

	# Debug and data logging every 2 frames
	# For Graphs
	if frame_num % 2 == 0:
		print(
			"frame =", frame_num,
			"speed_error =", speed_error,
			"speed =", measured_speed,
			"throttle =", current_esc_throttle,
			"steering_error=", error,
			"steer_der=", derivative,
			"steer_val=", steering_val
		)
	
	# Save data as well 
	csv_writer.writerow([
		frame_num,
		error,
		p_response,
		d_response,
		steering_val,
		current_esc_throttle,
		measured_speed
	])

	# Build debug frame
	debug = frame.copy()
	if lines is not None:
		for line in lines:
			x1, y1, x2, y2 = line[0]
			cv2.line(debug, (x1, y1), (x2, y2), (0, 255, 0), 2)
	cv2.line(debug, (w // 2, 0), (w // 2, h), (255, 0, 0), 1)
	cv2.line(debug, (road_center, 0), (road_center, h), (0, 0, 255), 1)

	with frame_lock:
		latest_frame = debug

	return frame

def main():
	print("\nArming ESC...")
	set_esc(0)
	time.sleep(3)
	print("ESC armed, starting navigation...")

	print("\nStart Navigation")

	try:
		MAX_FRAMES = 5000
		frame_count = 0

		# Main loop
		while frame_count < MAX_FRAMES:
			ret, frame = cap.read()

			if not ret:
				print("ERROR: Camera read failed at frame", frame_count)
				break

			display_frame = process_frame(frame, frame_count)

			frame_count += 1

		print("\nMax frames reached or manually quit.")

	except KeyboardInterrupt:
		print("\nKeyboardInterrupt detected. Stopping vehicle.")

	finally:
		print("Shutting down... setting neutral.")
		neutral()
		esc.detach()
		servo.detach()
		cap.release()

		csv_file.close()
		print("Data saved to run_data.csv")

if __name__ == "__main__":
	main()

Credits

Atishay Lalgudi
2 projects • 1 follower
Allen Mikhailov
2 projects • 2 followers
Evelyn Hardy
1 project • 1 follower
Joshua Aviles
1 project • 1 follower

Comments