Hackster will be offline on Monday, June 15 from 5pm to 7pm PDT to perform some scheduled maintenance.
Tom LuChenxin YanYixiang YuChengyun Tang
Published

Violet Drive

An autonomous RC car that follows lanes and stops at red markers using uses computer vision and real-time speed feedback.

IntermediateShowcase (no instructions)221
Violet Drive

Things used in this project

Hardware components

Raspberry Pi 4 Model B
Raspberry Pi 4 Model B
×1
Webcam, Logitech® HD Pro
Webcam, Logitech® HD Pro
×1
Portable Charger
×1
Optical Speed Encode
×1

Software apps and online services

OpenCV
OpenCV

Story

Read more

Code

ELEC553_final.py

Python
# Import required libraries
import cv2                      # For image processing
import numpy as np              # For numerical operations (arrays, masks, etc.)
import math                     # For trigonometric calculations
import RPi.GPIO as GPIO         # To interface with Raspberry Pi GPIO
import time                     # For timing operations like delays
import threading                # To run speed monitoring in parallel
import os                       # For checking and accessing file system paths

# Define GPIO pin assignments for steering, drive motor, and speed sensor
STEERING_PIN = 19               # GPIO pin controlling the steering servo
DRIVE_PIN = 18                  # GPIO pin for motor speed (hardware PWM)
SPEED_SENSOR_PIN = 17           # Input pin from wheel encoder (hall effect)

# Initialize GPIO pin modes and PWM control
GPIO.setmode(GPIO.BCM)                         # Use BCM pin numbering
#GPIO.setup(DRIVE_PIN, GPIO.OUT)
GPIO.setup(STEERING_PIN, GPIO.OUT)             # Steering pin set as output
GPIO.setup(SPEED_SENSOR_PIN, GPIO.IN)          # Speed sensor as input

#drive_pwm = GPIO.PWM(DRIVE_PIN, 50)
steering_pwm = GPIO.PWM(STEERING_PIN, 50)      # 50Hz PWM for steering servo
#drive_pwm.start(7.5)
steering_pwm.start(7.5)                        # Start at 7.5% duty (neutral steering)

# Setup for hardware PWM control via Linux sysfs interface
PWM_CHIP = "/sys/class/pwm/pwmchip0"           # Path to PWM chip
PWM_CHANNEL = f"{PWM_CHIP}/pwm0"               # Channel 0 under that chip

# If PWM channel is not yet exported (enabled), export it manually
if not os.path.exists(PWM_CHANNEL):
    with open(f"{PWM_CHIP}/export", 'w') as f:
        f.write("0")
    time.sleep(0.1)  # Give system time to create the path

# Set PWM frequency to 50Hz (i.e., 20,000,000 nanoseconds period)
with open(f"{PWM_CHANNEL}/period", 'w') as f:
    f.write("20000000")  # 20ms period for 50Hz

# Set initial duty cycle to 7.5% (corresponds to neutral throttle)
with open(f"{PWM_CHANNEL}/duty_cycle", 'w') as f:
    f.write(str(int(20000000 * 0.075)))  # 7.5% of 20ms = 1.5ms pulse width

# Enable the hardware PWM channel to begin sending signals
with open(f"{PWM_CHANNEL}/enable", 'w') as f:
    f.write("1")

# Initialize variables for wheel speed tracking
pulse_count = 0                  # Number of pulses received in current interval
current_rps = 0                  # Rotations per second, computed from pulses
target_rps = 2.3                 # Target RPS for cruise control
wheel_magnet_count = 20          # Pulses per wheel revolution

# Callback function for counting encoder pulses
def count_pulse(channel):
    global pulse_count
    pulse_count += 1             # Increment pulse count every falling edge

# Register the encoder pin to trigger count_pulse() on every falling edge
GPIO.add_event_detect(SPEED_SENSOR_PIN, GPIO.FALLING, callback=count_pulse)

# Background thread to compute real-time speed (RPS)
def speed_monitor():
    global pulse_count, current_rps
    while True:
        time.sleep(0.2)                              # Sample speed every 0.2s
        current_rps = pulse_count / wheel_magnet_count  # Compute RPS
        pulse_count = 0                              # Reset pulse count for next window

# Launch the speed monitor in a background thread
threading.Thread(target=speed_monitor, daemon=True).start()

# Convert input BGR image to HSV color space for easier color filtering
def convert_to_HSV(frame):
    return cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)

# Detect edges by filtering out blue lane lines, then applying Canny edge detection
def detect_edges(hsv):
    lower_blue = np.array([90, 120, 0], dtype=np.uint8)       # Lower HSV bound for blue
    upper_blue = np.array([150, 255, 255], dtype=np.uint8)    # Upper HSV bound for blue
    mask = cv2.inRange(hsv, lower_blue, upper_blue)           # Mask out only blue areas
    return cv2.Canny(mask, 50, 100)                            # Apply edge detection on mask

# Restrict detection area to lower part of image (road area) using a polygon mask
def region_of_interest(edges):
    height, width = edges.shape                               # Get image dimensions
    mask = np.zeros_like(edges)                               # Start with a black mask
    polygon = np.array([[(0, height), (0, height//1.2),       # Define a trapezoidal ROI
                         (width, height//1.2), (width, height)]], np.int32)
    cv2.fillPoly(mask, polygon, 255)                          # Fill ROI area with white
    return cv2.bitwise_and(edges, mask)                       # Keep only ROI part of edges

# Use Hough Transform to extract straight line segments from edge map
def detect_line_segments(cropped_edges):
    return cv2.HoughLinesP(
        cropped_edges, 1, np.pi / 180, 10, np.array([]),      # Angle resolution = 1, threshold = 10
        minLineLength=5, maxLineGap=0)                        # Keep small segments, no gap tolerance

# Average left and right lane lines based on slope and position
def average_slope_intercept(frame, line_segments):
    height, width, _ = frame.shape
    left_fit, right_fit = [], []                              # Store slope/intercepts separately

    if line_segments is None:                                 # If no lines found, return nothing
        return []

    for segment in line_segments:
        for x1, y1, x2, y2 in segment:
            if x1 == x2:                                      # Skip vertical lines (infinite slope)
                continue
            slope = (y2 - y1) / (x2 - x1)                      # Calculate slope
            intercept = y1 - slope * x1                       # Calculate y-intercept
            if slope < 0 and x1 < width * 2 / 3 and x2 < width * 2 / 3:
                left_fit.append((slope, intercept))           # Likely a left lane line
            elif slope >= 0 and x1 > width / 3 and x2 > width / 3:
                right_fit.append((slope, intercept))          # Likely a right lane line

    lane_lines = []
    if left_fit:
        lane_lines.append(make_points(frame, np.average(left_fit, axis=0)))   # Average left lines
    if right_fit:
        lane_lines.append(make_points(frame, np.average(right_fit, axis=0)))  # Average right lines
    return lane_lines

# Convert line slope and intercept into actual pixel coordinates to draw
def make_points(frame, line):
    slope, intercept = line
    height, width, _ = frame.shape
    y1 = height                                               # Bottom of the image
    y2 = int(height / 2)                                      # Middle of the image
    if slope == 0:
        slope = 0.1                                           # Avoid division by zero
    x1 = int((y1 - intercept) / slope)                        # Calculate x for y1
    x2 = int((y2 - intercept) / slope)                        # Calculate x for y2
    return [[x1, y1, x2, y2]]

# Draw detected lane lines on the image
def display_lines(frame, lines, color=(0, 255, 0), width=6):
    line_image = np.zeros_like(frame)                         # Blank image to draw on
    if lines:
        for x1, y1, x2, y2 in [line[0] for line in lines]:
            cv2.line(line_image, (x1, y1), (x2, y2), color, width)  # Draw each line
    return cv2.addWeighted(frame, 0.8, line_image, 1, 1)      # Overlay lines on original frame


# Compute the steering angle based on lane line positions
def get_steering_angle(frame, lane_lines):
    height, width, _ = frame.shape
    if len(lane_lines) == 2:
        _, _, lx2, _ = lane_lines[0][0]                       # x2 of left lane
        _, _, rx2, _ = lane_lines[1][0]                       # x2 of right lane
        x_offset = (lx2 + rx2) / 2 - width / 2                # Offset from image center
    elif len(lane_lines) == 1:
        x1, _, x2, _ = lane_lines[0][0]
        x_offset = x2 - x1                                    # Estimate curve direction
    else:
        x_offset = 0                                          # No line detected, go straight

    y_offset = height / 2                                     # Fixed vertical reference
    angle = math.atan2(x_offset, y_offset)                    # Calculate angle in radians
    return int(angle * 180.0 / math.pi) + 90                  # Convert to degrees (center = 90)

# Draw the steering direction on the output image
def display_heading_line(frame, angle, color=(0, 0, 255), width=5):
    heading_image = np.zeros_like(frame)
    height, width_frame, _ = frame.shape
    rad = math.radians(angle)
    x1 = width_frame // 2
    y1 = height
    x2 = int(x1 - height / 2 / math.tan(rad))                 # Project heading line
    y2 = height // 2
    cv2.line(heading_image, (x1, y1), (x2, y2), color, width)
    return cv2.addWeighted(frame, 0.8, heading_image, 1, 1)   # Overlay onto original frame

# Convert the steering angle into a PWM value for servo motor control
def angle_to_pwm(angle):
    angle = max(60, min(120, angle))                          # Clamp angle range
    if angle >= 90:
        return 7.5 - (angle - 90) / 0.7 * (1.6 / 30)
    else:
        return 7.5 - (angle - 90) / 1.5 * (1.5 / 30)

# PD controller configuration
last_error = 0                    # Store previous speed error for derivative term
last_time = time.time()          # Time of last update for derivative calculation
Kp = 0.4                         # Proportional gain: amplifies current speed error
Kd = Kp * 0.75                  # Derivative gain: dampens change rate of error
base_speed = 7.5                # Base PWM duty cycle (neutral driving value)

# Compute PWM throttle using PD control (based on difference between target and actual speed)
# NOTE: In the final version, this PD control is computed but not used (hardware PWM is fixed)
def compute_throttle_pwm(angle):
    global last_error, last_time
    now = time.time()                       # Get current time
    dt = now - last_time                    # Time elapsed since last update
    error = target_rps - current_rps        # Compute speed error

    if error < 0:                           # Avoid negative error (overspeeding)
        error = 0.01

    P = Kp * error                          # Proportional component
    D = Kd * (error - last_error) / dt if dt > 0 else 0  # Derivative component
    last_error = error                      # Update last error
    last_time = now                         # Update last time
    return min(7.99, max(7.9, base_speed + P + D))  # Return clamped PWM value

# Detect red regions in the frame using HSV color filtering
# Combines two HSV ranges to detect both ends of red spectrum
def detect_red_stop(frame):
    hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)  # Convert image to HSV color space

    # Red can appear in two HSV ranges: low (0-10) and high (160-180)
    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])

    # Create masks for both red ranges
    mask1 = cv2.inRange(hsv, lower_red1, upper_red1)
    mask2 = cv2.inRange(hsv, lower_red2, upper_red2)

    # Combine both red masks
    red_mask = cv2.bitwise_or(mask1, mask2)

    # Return number of red pixels (normalized)
    return np.sum(red_mask) / 255

# Video stream and smoothing buffers
video = cv2.VideoCapture(0)                          # Initialize webcam stream
video.set(cv2.CAP_PROP_FRAME_WIDTH, 320)             # Set frame width
video.set(cv2.CAP_PROP_FRAME_HEIGHT, 240)            # Set frame height

# Buffers to store previous PWM values for smoothing
steering_buffer = []
drive_buffer = []
STEERING_BUFFER_SIZE = 15
DRIVE_BUFFER_SIZE = 15

# Initialize stop detection state
red_detected_count = 0                               # How many stops encountered
red_detected = False                                 # Whether red is currently detected

# Main control loop
try:
    while True:
        ret, frame = video.read()                    # Capture a frame from camera
        if not ret:
            continue                                 # Skip if frame not valid

        # Function to set drive motor PWM using sysfs
        def set_hardware_pwm(duty_percent):
            duty_ns = int(20000000 * duty_percent / 100.0)  # Convert to nanoseconds
            with open(f"{PWM_CHANNEL}/duty_cycle", 'w') as f:
                f.write(str(duty_ns))

        # ---------- Image Processing Pipeline ----------
        hsv = convert_to_HSV(frame)                  # Convert to HSV
        edges = detect_edges(hsv)                    # Edge detection (blue lines)
        roi = region_of_interest(edges)              # Apply region mask
        segments = detect_line_segments(roi)         # Extract line segments
        lanes = average_slope_intercept(frame, segments)  # Fit left/right lane lines
        angle = get_steering_angle(frame, lanes)     # Determine steering angle
        lane_img = display_lines(frame, lanes)       # Draw lane lines
        final_img = display_heading_line(lane_img, angle)  # Draw heading line

        # ---------- Compute PWM values ----------
        target_steering_pwm = angle_to_pwm(angle)    # Convert angle to PWM for steering
        target_drive_pwm = compute_throttle_pwm(angle)  # Compute throttle (though unused)

        # ---------- Apply Moving Average ----------
        steering_buffer.append(target_steering_pwm)
        drive_buffer.append(target_drive_pwm)
        if len(steering_buffer) > STEERING_BUFFER_SIZE:
            steering_buffer.pop(0)
        if len(drive_buffer) > DRIVE_BUFFER_SIZE:
            drive_buffer.pop(0)
        average_steering_pwm = sum(steering_buffer) / len(steering_buffer)
        average_drive_pwm = sum(drive_buffer) / len(drive_buffer)

        # ---------- Stop Box Detection ----------
        red_area = detect_red_stop(frame)
        if red_area > 15000:                          # Large red area detected
            if not red_detected:
                red_detected = True
                red_detected_count += 1
                if red_detected_count == 1:           # First stop box
                    print("First stop detected: pausing 3 seconds")
                    time.sleep(1)
                    set_hardware_pwm(7.5)             # Pause motor
                    time.sleep(3)
                    set_hardware_pwm(average_drive_pwm)
                elif red_detected_count == 2:         # Second stop box
                    print("Second stop detected: stopping permanently")
                    time.sleep(0.5)
                    set_hardware_pwm(7.5)             # Final stop
                    break
        else:
            if red_area < 100:                        # Reset if red disappears
                red_detected = False

        # ---------- Apply PWM to Motors ----------
        steering_pwm.ChangeDutyCycle(average_steering_pwm)  # Steering servo
        set_hardware_pwm(average_drive_pwm)                 # Drive motor
        print(average_drive_pwm)

        # ---------- Show Camera Debug View ----------
        cv2.imshow("Lane Following", final_img)
        cv2.imshow("roi", roi)
        if cv2.waitKey(1) & 0xFF == 27:              # ESC key to exit
            break

# Shutdown and Cleanup
finally:
    video.release()
    cv2.destroyAllWindows()
    steering_pwm.ChangeDutyCycle(7.5)                # Reset steering to center
    set_hardware_pwm(7.5)                            # Reset drive motor
    time.sleep(1)
    steering_pwm.stop()
    #drive_pwm.stop()
    GPIO.cleanup()                                   # Release all GPIO resources

Credits

Tom Lu
1 project • 1 follower
Chenxin Yan
1 project • 1 follower
Yixiang Yu
1 project • 1 follower
Chengyun Tang
1 project • 1 follower

Comments