Simulating a Self-Driving Car in Python

By Charles LAZIOSI
Published on

Ever wondered how self-driving cars navigate complex environments? In this article, we'll explore how to simulate a self-driving car using Python and Pygame. We'll walk through building a simple car model equipped with virtual sensors, capable of navigating a track autonomously. Let's dive in!

  1. Introduction
  2. Prerequisites
  3. Project Overview
  4. Step-by-Step Implementation
  5. Full Source Code
  6. How It Works
  7. Running the Simulation
  8. Conclusion
  9. Next Steps
  10. Final Thoughts

Introduction

Self-driving cars combine various technologies like computer vision, sensor fusion, and intelligent decision-making to navigate roads safely. While building a real autonomous vehicle is a massive undertaking, simulating one provides valuable insights into the underlying principles. Using Python and Pygame, we can create a simplified environment to experiment with these concepts.

Prerequisites

  • Python 3.x installed on your system.
  • Pygame library installed. You can install it using:
    pip install pygame
    
  • Basic understanding of Python programming.

Project Overview

Our simulation will feature:

  • A car represented as a simple rectangle.
  • Virtual sensors that detect distances to track boundaries.
  • A track with inner and outer boundaries to navigate.
  • A decision-making algorithm to control the car based on sensor inputs.

Step-by-Step Implementation

1. Setting Up the Environment

First, we'll import the necessary libraries and initialize Pygame.

import pygame
import math
import sys

pygame.init()

# Screen dimensions
WIDTH, HEIGHT = 800, 600
win = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Self-Driving Car Simulation")

2. Defining the Car Class

The Car class will represent our vehicle. It will handle the car's position, movement, drawing, and sensor operations.

class Car:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.angle = 0  # Facing right initially
        self.speed = 0
        self.max_speed = 2
        self.acceleration = 0.05
        self.rotation_speed = 2
        self.length = 20
        self.width = 10
        self.sensors = []
        self.sensor_range = 100
        self.radius = max(self.length, self.width) / 2  # For collision detection

    def update(self):
        # Update position based on speed and angle
        rad = math.radians(self.angle)
        dx = self.speed * math.cos(rad)
        dy = self.speed * math.sin(rad)
        self.x += dx
        self.y += dy

        # Keep angle between 0 and 360 degrees
        self.angle %= 360

    def draw(self, win):
        # Draw the car as a rotated rectangle
        car_surface = pygame.Surface((self.width, self.length), pygame.SRCALPHA)
        car_surface.fill((0, 255, 0))  # Green color
        rotated_car = pygame.transform.rotate(car_surface, -self.angle)
        car_rect = rotated_car.get_rect(center=(self.x, self.y))
        win.blit(rotated_car, car_rect)

3. Implementing Sensors

Our car will use virtual sensors to detect walls. We'll simulate sensors using rays cast from the car in different directions.

    def cast_sensors(self, walls):
        self.sensors = []
        sensor_angles = [-90, -60, -30, 0, 30, 60, 90]  # More sensors for better detection
        for angle_offset in sensor_angles:
            angle = self.angle + angle_offset
            rad = math.radians(angle)
            end_x = self.x + self.sensor_range * math.cos(rad)
            end_y = self.y + self.sensor_range * math.sin(rad)
            sensor_line = ((self.x, self.y), (end_x, end_y))
            # Check for collision with walls
            collision_point = None
            min_distance = self.sensor_range
            for wall in walls:
                hit_point = line_rect_collision(sensor_line, wall)
                if hit_point:
                    distance = math.hypot(hit_point[0] - self.x, hit_point[1] - self.y)
                    if distance < min_distance:
                        min_distance = distance
                        collision_point = hit_point
            if collision_point:
                pygame.draw.line(win, (255, 0, 0), (self.x, self.y), collision_point, 1)
            else:
                pygame.draw.line(win, (255, 0, 0), (self.x, self.y), (end_x, end_y), 1)
            self.sensors.append(min_distance)

4. Collision Detection Functions

We need functions to detect collisions between sensor lines and walls.

def line_rect_collision(line, rect):
    x1, y1 = line[0]
    x2, y2 = line[1]
    # Get rect sides
    rect_lines = [
        ((rect.left, rect.top), (rect.right, rect.top)),
        ((rect.right, rect.top), (rect.right, rect.bottom)),
        ((rect.right, rect.bottom), (rect.left, rect.bottom)),
        ((rect.left, rect.bottom), (rect.left, rect.top))
    ]
    closest_point = None
    min_distance = float('inf')
    for rect_line in rect_lines:
        hit_point = line_line_collision(line, rect_line)
        if hit_point:
            distance = math.hypot(hit_point[0] - x1, hit_point[1] - y1)
            if distance < min_distance:
                min_distance = distance
                closest_point = hit_point
    return closest_point

def line_line_collision(line1, line2):
    # Line segments: line1 from (x1, y1) to (x2, y2), line2 from (x3, y3) to (x4, y4)
    x1, y1 = line1[0]
    x2, y2 = line1[1]
    x3, y3 = line2[0]
    x4, y4 = line2[1]

    denom = (y4 - y3)*(x2 - x1) - (x4 - x3)*(y2 - y1)
    if denom == 0:
        return None  # Lines are parallel

    ua = ((x4 - x3)*(y1 - y3) - (y4 - y3)*(x1 - x3)) / denom
    ub = -((x2 - x1)*(y1 - y3) - (y2 - y1)*(x1 - x3)) / denom

    if 0 <= ua <= 1 and 0 <= ub <= 1:
        # Intersection point is within both line segments
        x = x1 + ua*(x2 - x1)
        y = y1 + ua*(y2 - y1)
        return (x, y)
    else:
        return None

5. Creating the Track

We'll define a track with inner and outer boundaries to simulate a real driving environment.

def create_track():
    walls = []

    # Outer boundaries
    track_outer = [
        (100, 100, 600, 20),    # Top wall
        (680, 100, 20, 400),    # Right wall
        (100, 480, 600, 20),    # Bottom wall
        (100, 100, 20, 400)     # Left wall
    ]

    # Inner boundaries
    track_inner = [
        (200, 200, 400, 20),    # Top wall
        (580, 200, 20, 200),    # Right wall
        (200, 380, 400, 20),    # Bottom wall
        (200, 200, 20, 200)     # Left wall
    ]

    # Create walls for outer track
    for rect in track_outer:
        walls.append(pygame.Rect(rect))

    # Create walls for inner track
    for rect in track_inner:
        walls.append(pygame.Rect(rect))

    return walls

def draw_track(win, walls):
    for wall in walls:
        pygame.draw.rect(win, (0, 0, 0), wall)

6. Decision-Making Algorithm

Our car needs to make decisions based on sensor inputs. We'll implement a simple algorithm that adjusts the car's angle and speed.

def make_decision(car):
    sensors = car.sensors
    if sensors:
        front_sensor = sensors[3]
        left_sensors = sensors[:3]
        right_sensors = sensors[4:]

        # If an obstacle is detected in front, decide to turn
        if front_sensor < 30:
            if sum(left_sensors) > sum(right_sensors):
                car.angle -= car.rotation_speed  # Turn left
            else:
                car.angle += car.rotation_speed  # Turn right
            if car.speed > 0:
                car.speed -= car.acceleration  # Slow down
        else:
            # Adjust course slightly if side sensors detect walls
            if min(left_sensors) < 50:
                car.angle += car.rotation_speed / 2  # Adjust right
            elif min(right_sensors) < 50:
                car.angle -= car.rotation_speed / 2  # Adjust left
            if car.speed < car.max_speed:
                car.speed += car.acceleration  # Accelerate
    else:
        car.speed = 0  # Stop if no sensor data

7. Collision Handling

To prevent the car from going through walls, we'll implement collision detection.

def check_collision(car, walls):
    car_circle = pygame.Rect(
        car.x - car.radius, car.y - car.radius, car.radius*2, car.radius*2)
    for wall in walls:
        if car_circle.colliderect(wall):
            return True
    return False

8. Running the Simulation

We'll set up the main loop to run the simulation.

def main():
    # Starting position adjusted to the track
    car = Car(150, 300)
    walls = create_track()
    clock = pygame.time.Clock()
    run = True

    while run:
        clock.tick(60)
        win.fill((200, 200, 200))

        # Event handling
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                run = False

        # Update and draw track
        draw_track(win, walls)

        # Car operations
        car.cast_sensors(walls)
        make_decision(car)
        car.update()
        if check_collision(car, walls):
            # If collision detected, stop the car and reverse a bit
            car.speed = -car.max_speed / 2
            car.update()
            car.speed = 0
        car.draw(win)

        pygame.display.update()

    pygame.quit()
    sys.exit()

if __name__ == "__main__":
    main()

Full Source Code

Combining all the pieces, here's the complete code:

import pygame
import math
import sys

pygame.init()

# Screen dimensions
WIDTH, HEIGHT = 800, 600
win = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Self-Driving Car Simulation")

class Car:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.angle = 0  # Facing right initially
        self.speed = 0
        self.max_speed = 2
        self.acceleration = 0.05
        self.rotation_speed = 2
        self.length = 20
        self.width = 10
        self.sensors = []
        self.sensor_range = 100
        self.radius = max(self.length, self.width) / 2  # For collision detection

    def update(self):
        # Update position based on speed and angle
        rad = math.radians(self.angle)
        dx = self.speed * math.cos(rad)
        dy = self.speed * math.sin(rad)
        self.x += dx
        self.y += dy

        # Keep angle between 0 and 360 degrees
        self.angle %= 360

    def draw(self, win):
        # Draw the car as a rotated rectangle
        car_surface = pygame.Surface((self.width, self.length), pygame.SRCALPHA)
        car_surface.fill((0, 255, 0))  # Green color
        rotated_car = pygame.transform.rotate(car_surface, -self.angle)
        car_rect = rotated_car.get_rect(center=(self.x, self.y))
        win.blit(rotated_car, car_rect)

    def cast_sensors(self, walls):
        self.sensors = []
        sensor_angles = [-90, -60, -30, 0, 30, 60, 90]  # More sensors for better detection
        for angle_offset in sensor_angles:
            angle = self.angle + angle_offset
            rad = math.radians(angle)
            end_x = self.x + self.sensor_range * math.cos(rad)
            end_y = self.y + self.sensor_range * math.sin(rad)
            sensor_line = ((self.x, self.y), (end_x, end_y))
            # Check for collision with walls
            collision_point = None
            min_distance = self.sensor_range
            for wall in walls:
                hit_point = line_rect_collision(sensor_line, wall)
                if hit_point:
                    distance = math.hypot(hit_point[0] - self.x, hit_point[1] - self.y)
                    if distance < min_distance:
                        min_distance = distance
                        collision_point = hit_point
            if collision_point:
                pygame.draw.line(win, (255, 0, 0), (self.x, self.y), collision_point, 1)
            else:
                pygame.draw.line(win, (255, 0, 0), (self.x, self.y), (end_x, end_y), 1)
            self.sensors.append(min_distance)

def line_rect_collision(line, rect):
    x1, y1 = line[0]
    x2, y2 = line[1]
    # Get rect sides
    rect_lines = [
        ((rect.left, rect.top), (rect.right, rect.top)),
        ((rect.right, rect.top), (rect.right, rect.bottom)),
        ((rect.right, rect.bottom), (rect.left, rect.bottom)),
        ((rect.left, rect.bottom), (rect.left, rect.top))
    ]
    closest_point = None
    min_distance = float('inf')
    for rect_line in rect_lines:
        hit_point = line_line_collision(line, rect_line)
        if hit_point:
            distance = math.hypot(hit_point[0] - x1, hit_point[1] - y1)
            if distance < min_distance:
                min_distance = distance
                closest_point = hit_point
    return closest_point

def line_line_collision(line1, line2):
    # Line segments: line1 from (x1, y1) to (x2, y2), line2 from (x3, y3) to (x4, y4)
    x1, y1 = line1[0]
    x2, y2 = line1[1]
    x3, y3 = line2[0]
    x4, y4 = line2[1]

    denom = (y4 - y3)*(x2 - x1) - (x4 - x3)*(y2 - y1)
    if denom == 0:
        return None  # Lines are parallel

    ua = ((x4 - x3)*(y1 - y3) - (y4 - y3)*(x1 - x3)) / denom
    ub = -((x2 - x1)*(y1 - y3) - (y2 - y1)*(x1 - x3)) / denom

    if 0 <= ua <= 1 and 0 <= ub <= 1:
        # Intersection point is within both line segments
        x = x1 + ua*(x2 - x1)
        y = y1 + ua*(y2 - y1)
        return (x, y)
    else:
        return None

def create_track():
    walls = []

    # Outer boundaries
    track_outer = [
        (100, 100, 600, 20),    # Top wall
        (680, 100, 20, 400),    # Right wall
        (100, 480, 600, 20),    # Bottom wall
        (100, 100, 20, 400)     # Left wall
    ]

    # Inner boundaries
    track_inner = [
        (200, 200, 400, 20),    # Top wall
        (580, 200, 20, 200),    # Right wall
        (200, 380, 400, 20),    # Bottom wall
        (200, 200, 20, 200)     # Left wall
    ]

    # Create walls for outer track
    for rect in track_outer:
        walls.append(pygame.Rect(rect))

    # Create walls for inner track
    for rect in track_inner:
        walls.append(pygame.Rect(rect))

    return walls

def draw_track(win, walls):
    for wall in walls:
        pygame.draw.rect(win, (0, 0, 0), wall)

def make_decision(car):
    sensors = car.sensors
    if sensors:
        front_sensor = sensors[3]
        left_sensors = sensors[:3]
        right_sensors = sensors[4:]

        # If an obstacle is detected in front, decide to turn
        if front_sensor < 30:
            if sum(left_sensors) > sum(right_sensors):
                car.angle -= car.rotation_speed  # Turn left
            else:
                car.angle += car.rotation_speed  # Turn right
            if car.speed > 0:
                car.speed -= car.acceleration  # Slow down
        else:
            # Adjust course slightly if side sensors detect walls
            if min(left_sensors) < 50:
                car.angle += car.rotation_speed / 2  # Adjust right
            elif min(right_sensors) < 50:
                car.angle -= car.rotation_speed / 2  # Adjust left
            if car.speed < car.max_speed:
                car.speed += car.acceleration  # Accelerate
    else:
        car.speed = 0  # Stop if no sensor data

def check_collision(car, walls):
    car_circle = pygame.Rect(
        car.x - car.radius, car.y - car.radius, car.radius*2, car.radius*2)
    for wall in walls:
        if car_circle.colliderect(wall):
            return True
    return False

def main():
    # Starting position adjusted to the track
    car = Car(150, 300)
    walls = create_track()
    clock = pygame.time.Clock()
    run = True

    while run:
        clock.tick(60)
        win.fill((200, 200, 200))

        # Event handling
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                run = False

        # Update and draw track
        draw_track(win, walls)

        # Car operations
        car.cast_sensors(walls)
        make_decision(car)
        car.update()
        if check_collision(car, walls):
            # If collision detected, stop the car and reverse a bit
            car.speed = -car.max_speed / 2
            car.update()
            car.speed = 0
        car.draw(win)

        pygame.display.update()

    pygame.quit()
    sys.exit()

if __name__ == "__main__":
    main()

How It Works

  • Car Initialization: The car starts at position (150, 300) facing right.
  • Sensors: The car has seven sensors cast at angles relative to its current heading.
  • Track: The track consists of inner and outer walls, creating a loop for the car to navigate.
  • Decision-Making: The car adjusts its speed and angle based on sensor inputs to avoid walls and stay on track.
  • Collision Handling: If the car collides with a wall, it reverses slightly and stops to prevent passing through walls.

Running the Simulation

  1. Save the Code: Copy the complete code into a file named self_driving_car_simulation.py.

  2. Run the Script:

    python self_driving_car_simulation.py
    
  3. Observe: A window will open displaying the track and the car. The car will navigate the track autonomously.

Conclusion

This simulation demonstrates the basics of how a self-driving car perceives its environment and makes decisions based on sensor data. While simplified, it provides a foundation for understanding more complex concepts in autonomous vehicle navigation.

Next Steps

  • Enhance the Decision Algorithm: Implement advanced algorithms like PID controllers or machine learning models for smoother navigation.
  • Improve the Track: Create more complex tracks with curves and intersections.
  • Add Obstacles: Introduce dynamic obstacles to simulate real-world driving conditions.
  • User Control: Allow manual control of the car to compare with autonomous navigation.

Final Thoughts

Simulating a self-driving car in Python with Pygame is both educational and entertaining. It bridges the gap between theoretical concepts and practical implementation, offering a hands-on approach to learning about autonomous systems. Keep experimenting, and who knows—you might develop the next breakthrough in self-driving technology!


Note: This simulation is intended for educational purposes and simplifies many aspects of real-world autonomous driving systems.