0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

highway-envでレースしてみる#1

0
Posted at

最近F-1にハマっています。
自分でもF-1っぽいものを作れないかなと調べたところ、Pythonでできそうなものを見つけました。

highway-envを試してみる

PID制御での走行

ソースコードを書くこと自体にはあまり力を入れません。ChatGPTに丸投げです。
サーキットで車を走らせます。車は道路の中心を認識して、簡単なPID制御で走るようにします。

python
from __future__ import annotations

import argparse
import math
import time
from dataclasses import dataclass

import gymnasium as gym
import highway_env  # noqa: F401 - registers highway-env environments
import numpy as np

if hasattr(gym, "register_envs"):
    gym.register_envs(highway_env)


def clamp(value: float, low: float, high: float) -> float:
    return max(low, min(high, value))


def wrap_to_pi(angle: float) -> float:
    return (angle + math.pi) % (2 * math.pi) - math.pi


@dataclass
class PID:
    kp: float
    ki: float
    kd: float
    integral_limit: float = 10.0

    def __post_init__(self) -> None:
        self.integral = 0.0
        self.previous_error = 0.0
        self.has_previous = False

    def reset(self) -> None:
        self.integral = 0.0
        self.previous_error = 0.0
        self.has_previous = False

    def step(self, error: float, dt: float) -> float:
        self.integral = clamp(
            self.integral + error * dt,
            -self.integral_limit,
            self.integral_limit,
        )
        derivative = 0.0
        if self.has_previous and dt > 0:
            derivative = (error - self.previous_error) / dt
        self.previous_error = error
        self.has_previous = True
        return self.kp * error + self.ki * self.integral + self.kd * derivative


class CircuitPIDController:
    """Two small PID loops: one for speed, one for lane-center tracking."""

    def __init__(
        self,
        target_speed: float,
        speed_pid: PID,
        lateral_pid: PID,
        heading_gain: float,
    ) -> None:
        self.target_speed = target_speed
        self.speed_pid = speed_pid
        self.lateral_pid = lateral_pid
        self.heading_gain = heading_gain

    def reset(self) -> None:
        self.speed_pid.reset()
        self.lateral_pid.reset()

    def act(self, env: gym.Env, dt: float) -> np.ndarray:
        vehicle = env.unwrapped.vehicle
        lane = vehicle.lane

        lane_longitudinal, lane_lateral = lane.local_coordinates(vehicle.position)
        lane_heading = lane.heading_at(lane_longitudinal)
        heading_error = wrap_to_pi(lane_heading - vehicle.heading)

        speed_error = self.target_speed - vehicle.speed
        acceleration = self.speed_pid.step(speed_error, dt)

        # highway-env uses positive lateral coordinates on one side of the lane.
        # Steering toward -lane_lateral pulls the car back to the lane center.
        lateral_error = -lane_lateral
        steering = self.lateral_pid.step(lateral_error, dt) + self.heading_gain * heading_error

        return np.array(
            [
                clamp(acceleration, -1.0, 1.0),
                clamp(steering, -1.0, 1.0),
            ],
            dtype=np.float32,
        )


def make_env(
    env_id: str,
    render_mode: str | None,
    duration: int,
    other_vehicles: int,
) -> gym.Env:
    env = gym.make(env_id, render_mode=render_mode)
    env.unwrapped.configure(
        {
            "observation": {
                "type": "Kinematics",
                "vehicles_count": max(1, other_vehicles + 1),
                "features": ["presence", "x", "y", "vx", "vy", "cos_h", "sin_h"],
                "absolute": True,
                "normalize": False,
            },
            "action": {
                "type": "ContinuousAction",
                "longitudinal": True,
                "lateral": True,
                "dynamical": True,
            },
            "controlled_vehicles": 1,
            "other_vehicles": other_vehicles,
            "duration": duration,
            "simulation_frequency": 15,
            "policy_frequency": 15,
            "screen_width": 1000,
            "screen_height": 800,
            "centering_position": [0.5, 0.5],
            "scaling": 7.0,
            "show_trajectories": True,
            "terminate_off_road": True,
        }
    )
    return env


def run(args: argparse.Namespace) -> None:
    render_mode = "human" if not args.no_render else None
    env = make_env(args.env_id, render_mode, args.duration, args.traffic)
    controller = CircuitPIDController(
        target_speed=args.target_speed,
        speed_pid=PID(args.speed_kp, args.speed_ki, args.speed_kd),
        lateral_pid=PID(args.lateral_kp, args.lateral_ki, args.lateral_kd),
        heading_gain=args.heading_gain,
    )

    dt = 1.0 / env.unwrapped.config["policy_frequency"]
    obs, info = env.reset(seed=args.seed)
    controller.reset()

    total_reward = 0.0
    for step in range(args.duration * env.unwrapped.config["policy_frequency"]):
        action = controller.act(env, dt)
        obs, reward, terminated, truncated, info = env.step(action)
        total_reward += reward

        if args.log_every and step % args.log_every == 0:
            vehicle = env.unwrapped.vehicle
            _, lateral = vehicle.lane.local_coordinates(vehicle.position)
            print(
                f"step={step:04d} speed={vehicle.speed:5.2f}m/s "
                f"lateral_error={lateral:6.2f}m action={action.tolist()}"
            )

        if render_mode == "human":
            env.render()
            time.sleep(dt * args.slowdown)

        if terminated or truncated:
            break

    print(f"finished: steps={step + 1}, total_reward={total_reward:.2f}")
    env.close()


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="PID-controlled car on a highway-env racetrack circuit."
    )
    parser.add_argument(
        "--env-id",
        default="racetrack-v1",
        choices=[
            "racetrack-v0",
            "racetrack-v1",
            "racetrack-large-v0",
            "racetrack-large-v1",
            "racetrack-oval-v0",
            "racetrack-oval-v1",
        ],
        help="Racetrack variant.",
    )
    parser.add_argument("--target-speed", type=float, default=8.0, help="Target speed in m/s.")
    parser.add_argument("--duration", type=int, default=90, help="Episode duration in seconds.")
    parser.add_argument("--traffic", type=int, default=0, help="Number of other vehicles.")
    parser.add_argument("--seed", type=int, default=7)
    parser.add_argument("--no-render", action="store_true", help="Run headless.")
    parser.add_argument("--slowdown", type=float, default=0.8, help="Rendering delay multiplier.")
    parser.add_argument("--log-every", type=int, default=15, help="Print every N control steps; 0 disables.")

    parser.add_argument("--speed-kp", type=float, default=0.35)
    parser.add_argument("--speed-ki", type=float, default=0.03)
    parser.add_argument("--speed-kd", type=float, default=0.02)

    parser.add_argument("--lateral-kp", type=float, default=0.25)
    parser.add_argument("--lateral-ki", type=float, default=0.00)
    parser.add_argument("--lateral-kd", type=float, default=0.06)
    parser.add_argument("--heading-gain", type=float, default=1.3)
    return parser.parse_args()


if __name__ == "__main__":
    run(parse_args())

結果はこちら。
v1_1.png

v1_2.png

サーキットっぽい所を車が走っていますね。とりあえず上出来です。

GUI追加

PID制御のゲインを毎回ソースコードで設定するのは面倒なので、GUIのつまみで速度やゲインを調整できるようにしてみます。

from __future__ import annotations

import argparse
import math
import threading
import time
from dataclasses import dataclass
from typing import Any

import gymnasium as gym
import highway_env  # noqa: F401 - registers highway-env environments
import numpy as np

if hasattr(gym, "register_envs"):
    gym.register_envs(highway_env)


def clamp(value: float, low: float, high: float) -> float:
    return max(low, min(high, value))


def wrap_to_pi(angle: float) -> float:
    return (angle + math.pi) % (2 * math.pi) - math.pi


@dataclass
class PID:
    kp: float
    ki: float
    kd: float
    integral_limit: float = 10.0

    def __post_init__(self) -> None:
        self.integral = 0.0
        self.previous_error = 0.0
        self.has_previous = False

    def reset(self) -> None:
        self.integral = 0.0
        self.previous_error = 0.0
        self.has_previous = False

    def step(self, error: float, dt: float) -> float:
        self.integral = clamp(
            self.integral + error * dt,
            -self.integral_limit,
            self.integral_limit,
        )
        derivative = 0.0
        if self.has_previous and dt > 0:
            derivative = (error - self.previous_error) / dt
        self.previous_error = error
        self.has_previous = True
        return self.kp * error + self.ki * self.integral + self.kd * derivative


class CircuitPIDController:
    """Two small PID loops: one for speed, one for lane-center tracking."""

    def __init__(
        self,
        target_speed: float,
        speed_pid: PID,
        lateral_pid: PID,
        heading_gain: float,
    ) -> None:
        self.target_speed = target_speed
        self.speed_pid = speed_pid
        self.lateral_pid = lateral_pid
        self.heading_gain = heading_gain

    def reset(self) -> None:
        self.speed_pid.reset()
        self.lateral_pid.reset()

    def update_params(self, params: dict[str, float]) -> None:
        self.target_speed = params["target_speed"]
        self.speed_pid.kp = params["speed_kp"]
        self.speed_pid.ki = params["speed_ki"]
        self.speed_pid.kd = params["speed_kd"]
        self.lateral_pid.kp = params["lateral_kp"]
        self.lateral_pid.ki = params["lateral_ki"]
        self.lateral_pid.kd = params["lateral_kd"]
        self.heading_gain = params["heading_gain"]

    def act(self, env: gym.Env, dt: float) -> np.ndarray:
        vehicle = env.unwrapped.vehicle
        lane = vehicle.lane

        lane_longitudinal, lane_lateral = lane.local_coordinates(vehicle.position)
        lane_heading = lane.heading_at(lane_longitudinal)
        heading_error = wrap_to_pi(lane_heading - vehicle.heading)

        speed_error = self.target_speed - vehicle.speed
        acceleration = self.speed_pid.step(speed_error, dt)

        # highway-env uses positive lateral coordinates on one side of the lane.
        # Steering toward -lane_lateral pulls the car back to the lane center.
        lateral_error = -lane_lateral
        steering = self.lateral_pid.step(lateral_error, dt) + self.heading_gain * heading_error

        return np.array(
            [
                clamp(acceleration, -1.0, 1.0),
                clamp(steering, -1.0, 1.0),
            ],
            dtype=np.float32,
        )


class SharedControls:
    def __init__(self, args: argparse.Namespace) -> None:
        self._lock = threading.Lock()
        self.params = {
            "target_speed": args.target_speed,
            "speed_kp": args.speed_kp,
            "speed_ki": args.speed_ki,
            "speed_kd": args.speed_kd,
            "lateral_kp": args.lateral_kp,
            "lateral_ki": args.lateral_ki,
            "lateral_kd": args.lateral_kd,
            "heading_gain": args.heading_gain,
            "slowdown": args.slowdown,
        }
        self.metrics: dict[str, float] = {
            "step": 0.0,
            "speed": 0.0,
            "lateral_error": 0.0,
            "acceleration": 0.0,
            "steering": 0.0,
        }
        self.reset_requested = False
        self.quit_requested = False

    def set_param(self, name: str, value: float) -> None:
        with self._lock:
            self.params[name] = value

    def request_reset(self) -> None:
        with self._lock:
            self.reset_requested = True

    def request_quit(self) -> None:
        with self._lock:
            self.quit_requested = True

    def snapshot(self) -> tuple[dict[str, float], bool, bool]:
        with self._lock:
            params = dict(self.params)
            reset_requested = self.reset_requested
            quit_requested = self.quit_requested
            self.reset_requested = False
        return params, reset_requested, quit_requested

    def update_metrics(self, **metrics: float) -> None:
        with self._lock:
            self.metrics.update(metrics)

    def metrics_snapshot(self) -> dict[str, float]:
        with self._lock:
            return dict(self.metrics)


def start_control_panel(shared: SharedControls) -> threading.Thread:
    thread = threading.Thread(target=run_control_panel, args=(shared,), daemon=True)
    thread.start()
    return thread


def run_control_panel(shared: SharedControls) -> None:
    import tkinter as tk
    from tkinter import ttk

    root = tk.Tk()
    root.title("PID controls")
    root.geometry("420x620")

    main = ttk.Frame(root, padding=12)
    main.pack(fill="both", expand=True)

    status = tk.StringVar(value="starting...")
    ttk.Label(main, textvariable=status, justify="left").pack(fill="x", pady=(0, 12))

    initial = shared.snapshot()[0]

    def add_slider(
        name: str,
        label: str,
        low: float,
        high: float,
        step: float,
    ) -> None:
        row = ttk.Frame(main)
        row.pack(fill="x", pady=5)

        value = tk.DoubleVar(value=initial[name])
        text = tk.StringVar(value=f"{label}: {value.get():.3f}")

        def changed(raw_value: Any) -> None:
            rounded = round(float(raw_value) / step) * step
            shared.set_param(name, rounded)
            text.set(f"{label}: {rounded:.3f}")

        ttk.Label(row, textvariable=text, width=24).pack(side="left")
        scale = ttk.Scale(row, from_=low, to=high, orient="horizontal", variable=value, command=changed)
        scale.pack(side="left", fill="x", expand=True)

    add_slider("target_speed", "target speed [m/s]", 0.0, 20.0, 0.1)
    add_slider("speed_kp", "speed Kp", 0.0, 2.0, 0.01)
    add_slider("speed_ki", "speed Ki", 0.0, 0.5, 0.001)
    add_slider("speed_kd", "speed Kd", 0.0, 0.5, 0.001)
    add_slider("lateral_kp", "lateral Kp", 0.0, 2.0, 0.01)
    add_slider("lateral_ki", "lateral Ki", 0.0, 0.5, 0.001)
    add_slider("lateral_kd", "lateral Kd", 0.0, 0.5, 0.001)
    add_slider("heading_gain", "heading gain", 0.0, 4.0, 0.01)
    add_slider("slowdown", "render slowdown", 0.0, 2.0, 0.01)

    buttons = ttk.Frame(main)
    buttons.pack(fill="x", pady=(16, 0))
    ttk.Button(buttons, text="Reset PID memory", command=shared.request_reset).pack(side="left")
    ttk.Button(buttons, text="Quit", command=shared.request_quit).pack(side="right")

    def refresh_status() -> None:
        metrics = shared.metrics_snapshot()
        status.set(
            "step={step:.0f}\n"
            "speed={speed:.2f} m/s\n"
            "lateral_error={lateral_error:.2f} m\n"
            "accel={acceleration:.3f}, steering={steering:.3f}".format(**metrics)
        )
        root.after(100, refresh_status)

    def close() -> None:
        shared.request_quit()
        root.destroy()

    root.protocol("WM_DELETE_WINDOW", close)
    refresh_status()
    root.mainloop()


def make_env(
    env_id: str,
    render_mode: str | None,
    duration: int,
    other_vehicles: int,
) -> gym.Env:
    env = gym.make(env_id, render_mode=render_mode)
    env.unwrapped.configure(
        {
            "observation": {
                "type": "Kinematics",
                "vehicles_count": max(1, other_vehicles + 1),
                "features": ["presence", "x", "y", "vx", "vy", "cos_h", "sin_h"],
                "absolute": True,
                "normalize": False,
            },
            "action": {
                "type": "ContinuousAction",
                "longitudinal": True,
                "lateral": True,
                "dynamical": True,
            },
            "controlled_vehicles": 1,
            "other_vehicles": other_vehicles,
            "duration": duration,
            "simulation_frequency": 15,
            "policy_frequency": 15,
            "screen_width": 1000,
            "screen_height": 800,
            "centering_position": [0.5, 0.5],
            "scaling": 7.0,
            "show_trajectories": True,
            "terminate_off_road": True,
        }
    )
    return env


def run(args: argparse.Namespace) -> None:
    render_mode = "human" if not args.no_render else None
    env = make_env(args.env_id, render_mode, args.duration, args.traffic)
    shared = SharedControls(args)
    if args.gui:
        start_control_panel(shared)

    controller = CircuitPIDController(
        target_speed=args.target_speed,
        speed_pid=PID(args.speed_kp, args.speed_ki, args.speed_kd),
        lateral_pid=PID(args.lateral_kp, args.lateral_ki, args.lateral_kd),
        heading_gain=args.heading_gain,
    )

    dt = 1.0 / env.unwrapped.config["policy_frequency"]
    obs, info = env.reset(seed=args.seed)
    controller.reset()

    total_reward = 0.0
    for step in range(args.duration * env.unwrapped.config["policy_frequency"]):
        params, reset_requested, quit_requested = shared.snapshot()
        controller.update_params(params)
        if reset_requested:
            controller.reset()
        if quit_requested:
            break

        action = controller.act(env, dt)
        obs, reward, terminated, truncated, info = env.step(action)
        total_reward += reward

        vehicle = env.unwrapped.vehicle
        _, lateral = vehicle.lane.local_coordinates(vehicle.position)
        shared.update_metrics(
            step=float(step),
            speed=float(vehicle.speed),
            lateral_error=float(lateral),
            acceleration=float(action[0]),
            steering=float(action[1]),
        )

        if args.log_every and step % args.log_every == 0:
            print(
                f"step={step:04d} speed={vehicle.speed:5.2f}m/s "
                f"lateral_error={lateral:6.2f}m action={action.tolist()}"
            )

        if render_mode == "human":
            env.render()
            time.sleep(dt * params["slowdown"])

        if terminated or truncated:
            break

    print(f"finished: steps={step + 1}, total_reward={total_reward:.2f}")
    env.close()


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="PID-controlled car on a highway-env racetrack circuit."
    )
    parser.add_argument(
        "--env-id",
        default="racetrack-v1",
        choices=[
            "racetrack-v0",
            "racetrack-v1",
            "racetrack-large-v0",
            "racetrack-large-v1",
            "racetrack-oval-v0",
            "racetrack-oval-v1",
        ],
        help="Racetrack variant.",
    )
    parser.add_argument("--target-speed", type=float, default=8.0, help="Target speed in m/s.")
    parser.add_argument("--duration", type=int, default=90, help="Episode duration in seconds.")
    parser.add_argument("--traffic", type=int, default=0, help="Number of other vehicles.")
    parser.add_argument("--seed", type=int, default=7)
    parser.add_argument("--no-render", action="store_true", help="Run headless.")
    parser.add_argument("--gui", action=argparse.BooleanOptionalAction, default=True)
    parser.add_argument("--slowdown", type=float, default=0.8, help="Rendering delay multiplier.")
    parser.add_argument("--log-every", type=int, default=15, help="Print every N control steps; 0 disables.")

    parser.add_argument("--speed-kp", type=float, default=0.35)
    parser.add_argument("--speed-ki", type=float, default=0.03)
    parser.add_argument("--speed-kd", type=float, default=0.02)

    parser.add_argument("--lateral-kp", type=float, default=0.25)
    parser.add_argument("--lateral-ki", type=float, default=0.00)
    parser.add_argument("--lateral-kd", type=float, default=0.06)
    parser.add_argument("--heading-gain", type=float, default=1.3)
    return parser.parse_args()


if __name__ == "__main__":
    run(parse_args())

結果はこちら。
v2_1.png

v2_2.png

GUIで調整できるようになりました。

ステアリング、アクセルペダル、ブレーキペダル

車を走らせることを想定すると、現実的にはステアリング、アクセルペダル、ブレーキペダルの3つを操作することになるはずです、
今はPIDで速度と先頭の方向を調整しているだけなので少し味気ないですね。
現実的に寄せてみることにします。

from __future__ import annotations

import argparse
import math
import threading
import time
from dataclasses import dataclass
from typing import Any

import gymnasium as gym
import highway_env  # noqa: F401 - registers highway-env environments
import numpy as np

if hasattr(gym, "register_envs"):
    gym.register_envs(highway_env)


def clamp(value: float, low: float, high: float) -> float:
    return max(low, min(high, value))


def wrap_to_pi(angle: float) -> float:
    return (angle + math.pi) % (2 * math.pi) - math.pi


@dataclass
class PID:
    kp: float
    ki: float
    kd: float
    integral_limit: float = 10.0

    def __post_init__(self) -> None:
        self.integral = 0.0
        self.previous_error = 0.0
        self.has_previous = False

    def reset(self) -> None:
        self.integral = 0.0
        self.previous_error = 0.0
        self.has_previous = False

    def step(self, error: float, dt: float) -> float:
        self.integral = clamp(
            self.integral + error * dt,
            -self.integral_limit,
            self.integral_limit,
        )
        derivative = 0.0
        if self.has_previous and dt > 0:
            derivative = (error - self.previous_error) / dt
        self.previous_error = error
        self.has_previous = True
        return self.kp * error + self.ki * self.integral + self.kd * derivative


@dataclass(frozen=True)
class VehicleCommand:
    accelerator: float
    brake: float
    steering: float
    virtual_drag: float

    def as_highway_env_action(self) -> np.ndarray:
        # highway-env ContinuousAction receives one longitudinal command.
        # Keep pedals separate for learning, then add a small resistance model.
        longitudinal = self.accelerator - self.brake - self.virtual_drag
        return np.array([longitudinal, self.steering], dtype=np.float32)


class CircuitPIDController:
    """Speed planning plus two PID loops for speed and lane-center tracking."""

    def __init__(
        self,
        target_speed: float,
        speed_pid: PID,
        lateral_pid: PID,
        heading_gain: float,
        cruise_throttle: float,
        curve_lookahead: float,
        rolling_drag: float,
        aero_drag: float,
        corner_drag: float,
        brake_deadband: float,
        pedal_smoothing: float,
    ) -> None:
        self.target_speed = target_speed
        self.speed_pid = speed_pid
        self.lateral_pid = lateral_pid
        self.heading_gain = heading_gain
        self.cruise_throttle = cruise_throttle
        self.curve_lookahead = curve_lookahead
        self.rolling_drag = rolling_drag
        self.aero_drag = aero_drag
        self.corner_drag = corner_drag
        self.brake_deadband = brake_deadband
        self.pedal_smoothing = pedal_smoothing
        self.speed_setpoint = target_speed
        self.curve_severity = 0.0
        self.virtual_drag = 0.0
        self.previous_accelerator = 0.0
        self.previous_brake = 0.0
        self.previous_steering = 0.0

    def reset(self) -> None:
        self.speed_pid.reset()
        self.lateral_pid.reset()
        self.previous_accelerator = 0.0
        self.previous_brake = 0.0
        self.previous_steering = 0.0

    def update_params(self, params: dict[str, float]) -> None:
        self.target_speed = params["target_speed"]
        self.speed_pid.kp = params["speed_kp"]
        self.speed_pid.ki = params["speed_ki"]
        self.speed_pid.kd = params["speed_kd"]
        self.lateral_pid.kp = params["lateral_kp"]
        self.lateral_pid.ki = params["lateral_ki"]
        self.lateral_pid.kd = params["lateral_kd"]
        self.heading_gain = params["heading_gain"]
        self.cruise_throttle = params["cruise_throttle"]
        self.curve_lookahead = params["curve_lookahead"]
        self.rolling_drag = params["rolling_drag"]
        self.aero_drag = params["aero_drag"]
        self.corner_drag = params["corner_drag"]
        self.brake_deadband = params["brake_deadband"]
        self.pedal_smoothing = params["pedal_smoothing"]

    def act(self, env: gym.Env, dt: float) -> VehicleCommand:
        vehicle = env.unwrapped.vehicle
        lane = vehicle.lane

        lane_longitudinal, lane_lateral = lane.local_coordinates(vehicle.position)
        lane_heading = lane.heading_at(lane_longitudinal)
        heading_error = wrap_to_pi(lane_heading - vehicle.heading)
        self.curve_severity = self.estimate_curve_severity(lane, lane_longitudinal, lane_heading)
        self.speed_setpoint = self.target_speed
        self.virtual_drag = self.estimate_virtual_drag(vehicle.speed)

        speed_error = self.speed_setpoint - vehicle.speed
        longitudinal_output = clamp(self.speed_pid.step(speed_error, dt), -1.0, 1.0)
        overspeed = max(0.0, vehicle.speed - self.speed_setpoint - self.brake_deadband)
        brake_raw = clamp(max(-longitudinal_output, overspeed / 5.0), 0.0, 1.0)
        accelerator_raw = clamp(
            self.virtual_drag + self.cruise_throttle + max(0.0, longitudinal_output) - 0.7 * brake_raw,
            0.0,
            1.0,
        )

        # highway-env uses positive lateral coordinates on one side of the lane.
        # Steering toward -lane_lateral pulls the car back to the lane center.
        lateral_error = -lane_lateral
        steering = self.lateral_pid.step(lateral_error, dt) + self.heading_gain * heading_error
        steering = clamp(steering, -1.0, 1.0)
        accelerator = self.smooth(self.previous_accelerator, accelerator_raw, dt)
        brake = self.smooth(self.previous_brake, brake_raw, dt)
        steering = self.smooth(self.previous_steering, steering, dt)
        self.previous_accelerator = accelerator
        self.previous_brake = brake
        self.previous_steering = steering

        return VehicleCommand(
            accelerator=accelerator,
            brake=brake,
            steering=steering,
            virtual_drag=self.virtual_drag,
        )

    def estimate_virtual_drag(self, speed: float) -> float:
        speed_ratio = clamp(speed / max(self.target_speed, 1.0), 0.0, 2.0)
        rolling = self.rolling_drag
        aero = self.aero_drag * speed_ratio * speed_ratio
        corner = self.corner_drag * self.curve_severity * speed_ratio
        return clamp(rolling + aero + corner, 0.0, 0.9)

    def smooth(self, previous: float, current: float, dt: float) -> float:
        if self.pedal_smoothing <= 0:
            return current
        alpha = clamp(dt / (self.pedal_smoothing + dt), 0.0, 1.0)
        return previous + alpha * (current - previous)

    def estimate_curve_severity(self, lane: Any, longitudinal: float, heading: float) -> float:
        if self.curve_lookahead <= 0:
            return 0.0

        max_heading_change = 0.0
        for ratio in (0.33, 0.66, 1.0):
            try:
                future_heading = lane.heading_at(longitudinal + self.curve_lookahead * ratio)
            except Exception:
                continue
            max_heading_change = max(max_heading_change, abs(wrap_to_pi(future_heading - heading)))
        return max_heading_change


class SharedControls:
    def __init__(self, args: argparse.Namespace) -> None:
        self._lock = threading.Lock()
        self.params = {
            "target_speed": args.target_speed,
            "speed_kp": args.speed_kp,
            "speed_ki": args.speed_ki,
            "speed_kd": args.speed_kd,
            "lateral_kp": args.lateral_kp,
            "lateral_ki": args.lateral_ki,
            "lateral_kd": args.lateral_kd,
            "heading_gain": args.heading_gain,
            "cruise_throttle": args.cruise_throttle,
            "curve_lookahead": args.curve_lookahead,
            "rolling_drag": args.rolling_drag,
            "aero_drag": args.aero_drag,
            "corner_drag": args.corner_drag,
            "brake_deadband": args.brake_deadband,
            "pedal_smoothing": args.pedal_smoothing,
            "slowdown": args.slowdown,
        }
        self.metrics: dict[str, float] = {
            "step": 0.0,
            "speed": 0.0,
            "speed_setpoint": args.target_speed,
            "curve_severity": 0.0,
            "virtual_drag": 0.0,
            "lateral_error": 0.0,
            "accelerator": 0.0,
            "brake": 0.0,
            "steering": 0.0,
        }
        self.reset_requested = False
        self.quit_requested = False

    def set_param(self, name: str, value: float) -> None:
        with self._lock:
            self.params[name] = value

    def request_reset(self) -> None:
        with self._lock:
            self.reset_requested = True

    def request_quit(self) -> None:
        with self._lock:
            self.quit_requested = True

    def snapshot(self) -> tuple[dict[str, float], bool, bool]:
        with self._lock:
            params = dict(self.params)
            reset_requested = self.reset_requested
            quit_requested = self.quit_requested
            self.reset_requested = False
        return params, reset_requested, quit_requested

    def update_metrics(self, **metrics: float) -> None:
        with self._lock:
            self.metrics.update(metrics)

    def metrics_snapshot(self) -> dict[str, float]:
        with self._lock:
            return dict(self.metrics)


def start_control_panel(shared: SharedControls) -> threading.Thread:
    thread = threading.Thread(target=run_control_panel, args=(shared,), daemon=True)
    thread.start()
    return thread


def run_control_panel(shared: SharedControls) -> None:
    import tkinter as tk
    from tkinter import ttk

    root = tk.Tk()
    root.title("PID driving controls")
    root.geometry("760x650")
    root.minsize(680, 560)

    style = ttk.Style(root)
    style.configure("Title.TLabel", font=("Segoe UI", 14, "bold"))
    style.configure("Metric.TLabel", font=("Consolas", 11))
    style.configure("Section.TLabelframe.Label", font=("Segoe UI", 10, "bold"))

    main = ttk.Frame(root, padding=12)
    main.pack(fill="both", expand=True)

    header = ttk.Frame(main)
    header.pack(fill="x", pady=(0, 8))
    ttk.Label(header, text="PID Driving Monitor", style="Title.TLabel").pack(side="left")
    status = tk.StringVar(value="starting...")
    ttk.Label(header, textvariable=status, style="Metric.TLabel", justify="right").pack(side="right")

    visual_frame = ttk.LabelFrame(main, text="Live operation", padding=10, style="Section.TLabelframe")
    visual_frame.pack(fill="x", pady=(0, 10))

    dashboard = tk.Canvas(
        visual_frame,
        width=720,
        height=230,
        bg="#111827",
        highlightthickness=0,
    )
    dashboard.pack(fill="x", expand=True)

    initial = shared.snapshot()[0]

    controls = ttk.Frame(main)
    controls.pack(fill="both", expand=True)

    class CollapsibleSection:
        def __init__(self, parent: ttk.Frame, title: str, open_by_default: bool = False) -> None:
            self.is_open = tk.BooleanVar(value=open_by_default)
            self.frame = ttk.Frame(parent)
            self.frame.pack(fill="x", pady=3)

            self.button = ttk.Button(self.frame, command=self.toggle)
            self.button.pack(fill="x")

            self.body = ttk.Frame(self.frame, padding=(10, 8, 10, 4))
            self.title = title
            self.refresh()

        def toggle(self) -> None:
            self.is_open.set(not self.is_open.get())
            self.refresh()

        def refresh(self) -> None:
            mark = "-" if self.is_open.get() else "+"
            self.button.configure(text=f"{mark} {self.title}")
            if self.is_open.get():
                self.body.pack(fill="x")
            else:
                self.body.pack_forget()

    speed_section = CollapsibleSection(controls, "Speed PID", open_by_default=True)
    lateral_section = CollapsibleSection(controls, "Lateral PID", open_by_default=False)
    vehicle_section = CollapsibleSection(controls, "Vehicle model", open_by_default=False)
    runtime_section = CollapsibleSection(controls, "Runtime", open_by_default=True)

    def add_slider(
        parent: ttk.Frame,
        name: str,
        label: str,
        low: float,
        high: float,
        step: float,
    ) -> None:
        row = ttk.Frame(parent)
        row.pack(fill="x", pady=3)

        value = tk.DoubleVar(value=initial[name])
        text = tk.StringVar(value=f"{label}: {value.get():.3f}")

        def changed(raw_value: Any) -> None:
            rounded = round(float(raw_value) / step) * step
            shared.set_param(name, rounded)
            text.set(f"{label}: {rounded:.3f}")

        ttk.Label(row, textvariable=text, width=24).pack(side="left")
        scale = ttk.Scale(row, from_=low, to=high, orient="horizontal", variable=value, command=changed)
        scale.pack(side="left", fill="x", expand=True)

    add_slider(speed_section.body, "target_speed", "target speed [m/s]", 0.0, 20.0, 0.1)
    add_slider(speed_section.body, "speed_kp", "speed Kp", 0.0, 2.0, 0.01)
    add_slider(speed_section.body, "speed_ki", "speed Ki", 0.0, 0.5, 0.001)
    add_slider(speed_section.body, "speed_kd", "speed Kd", 0.0, 0.5, 0.001)

    add_slider(lateral_section.body, "lateral_kp", "lateral Kp", 0.0, 2.0, 0.01)
    add_slider(lateral_section.body, "lateral_ki", "lateral Ki", 0.0, 0.5, 0.001)
    add_slider(lateral_section.body, "lateral_kd", "lateral Kd", 0.0, 0.5, 0.001)
    add_slider(lateral_section.body, "heading_gain", "heading gain", 0.0, 4.0, 0.01)

    add_slider(vehicle_section.body, "cruise_throttle", "cruise throttle", 0.0, 0.5, 0.01)
    add_slider(vehicle_section.body, "curve_lookahead", "curve lookahead [m]", 0.0, 80.0, 1.0)
    add_slider(vehicle_section.body, "rolling_drag", "rolling drag", 0.0, 0.3, 0.005)
    add_slider(vehicle_section.body, "aero_drag", "aero drag", 0.0, 0.5, 0.005)
    add_slider(vehicle_section.body, "corner_drag", "corner drag", 0.0, 1.5, 0.01)
    add_slider(vehicle_section.body, "brake_deadband", "brake deadband [m/s]", 0.0, 4.0, 0.1)
    add_slider(vehicle_section.body, "pedal_smoothing", "pedal smoothing [s]", 0.0, 2.0, 0.05)

    add_slider(runtime_section.body, "slowdown", "render slowdown", 0.0, 2.0, 0.01)

    buttons = ttk.Frame(main)
    buttons.pack(fill="x", pady=(16, 0))
    ttk.Button(buttons, text="Reset PID memory", command=shared.request_reset).pack(side="left")
    ttk.Button(buttons, text="Quit", command=shared.request_quit).pack(side="right")

    def draw_pedal(
        x: int,
        y: int,
        width: int,
        height: int,
        value: float,
        color: str,
        label: str,
    ) -> None:
        value = clamp(value, 0.0, 1.0)
        fill_height = int(height * value)
        dashboard.create_rectangle(x, y, x + width, y + height, outline="#4b5563", width=2)
        dashboard.create_rectangle(
            x + 4,
            y + height - fill_height + 4,
            x + width - 4,
            y + height - 4,
            fill=color,
            outline="",
        )
        dashboard.create_text(
            x + width / 2,
            y + height + 18,
            text=label,
            fill="#e5e7eb",
            font=("Segoe UI", 10, "bold"),
        )
        dashboard.create_text(
            x + width / 2,
            y - 14,
            text=f"{value:.2f}",
            fill=color,
            font=("Consolas", 12, "bold"),
        )

    def draw_steering(center_x: int, center_y: int, radius: int, steering: float) -> None:
        steering = clamp(steering, -1.0, 1.0)
        angle = steering * math.radians(90)
        dashboard.create_oval(
            center_x - radius,
            center_y - radius,
            center_x + radius,
            center_y + radius,
            outline="#e5e7eb",
            width=8,
        )
        dashboard.create_oval(
            center_x - 9,
            center_y - 9,
            center_x + 9,
            center_y + 9,
            fill="#e5e7eb",
            outline="",
        )
        for base_angle in (math.radians(-90), math.radians(30), math.radians(150)):
            spoke = base_angle + angle
            end_x = center_x + math.cos(spoke) * (radius - 8)
            end_y = center_y + math.sin(spoke) * (radius - 8)
            dashboard.create_line(center_x, center_y, end_x, end_y, fill="#e5e7eb", width=5)

        needle_x = center_x + math.sin(angle) * (radius + 24)
        needle_y = center_y - math.cos(angle) * (radius + 24)
        dashboard.create_line(center_x, center_y, needle_x, needle_y, fill="#38bdf8", width=4, arrow=tk.LAST)
        dashboard.create_text(
            center_x,
            center_y + radius + 28,
            text=f"STEERING {steering:+.2f}",
            fill="#38bdf8",
            font=("Consolas", 13, "bold"),
        )

    def draw_dashboard(metrics: dict[str, float]) -> None:
        dashboard.delete("all")
        dashboard.create_text(
            24,
            22,
            text=f"speed {metrics['speed']:.2f} m/s",
            anchor="w",
            fill="#f9fafb",
            font=("Consolas", 14, "bold"),
        )
        dashboard.create_text(
            24,
            48,
            text=f"speed setpoint {metrics['speed_setpoint']:.2f} m/s",
            anchor="w",
            fill="#cbd5e1",
            font=("Consolas", 12),
        )
        dashboard.create_text(
            24,
            72,
            text=f"lateral error {metrics['lateral_error']:+.2f} m",
            anchor="w",
            fill="#cbd5e1",
            font=("Consolas", 12),
        )
        dashboard.create_text(
            24,
            96,
            text=f"curve severity {metrics['curve_severity']:.2f} rad",
            anchor="w",
            fill="#cbd5e1",
            font=("Consolas", 12),
        )
        dashboard.create_text(
            24,
            120,
            text=f"virtual drag {metrics['virtual_drag']:.3f}",
            anchor="w",
            fill="#cbd5e1",
            font=("Consolas", 12),
        )
        dashboard.create_line(360, 36, 660, 36, fill="#374151", width=2)
        dashboard.create_text(360, 20, text="left", anchor="w", fill="#9ca3af", font=("Segoe UI", 9))
        dashboard.create_text(660, 20, text="right", anchor="e", fill="#9ca3af", font=("Segoe UI", 9))

        steering_x = 510 + metrics["steering"] * 120
        dashboard.create_line(510, 30, steering_x, 30, fill="#38bdf8", width=5)
        dashboard.create_oval(steering_x - 7, 23, steering_x + 7, 37, fill="#38bdf8", outline="")

        draw_steering(230, 140, 60, metrics["steering"])
        draw_pedal(455, 78, 70, 118, metrics["accelerator"], "#22c55e", "ACCEL")
        draw_pedal(570, 78, 70, 118, metrics["brake"], "#ef4444", "BRAKE")

    def refresh_status() -> None:
        metrics = shared.metrics_snapshot()
        draw_dashboard(metrics)
        status.set(
            "step={step:.0f}\n"
            "speed_setpoint={speed_setpoint:.2f} m/s\n"
            "accelerator={accelerator:.3f}, brake={brake:.3f}\n"
            "steering={steering:.3f}".format(**metrics)
        )
        root.after(100, refresh_status)

    def close() -> None:
        shared.request_quit()
        root.destroy()

    root.protocol("WM_DELETE_WINDOW", close)
    refresh_status()
    root.mainloop()


def make_env(
    env_id: str,
    render_mode: str | None,
    duration: int,
    other_vehicles: int,
) -> gym.Env:
    env = gym.make(env_id, render_mode=render_mode)
    env.unwrapped.configure(
        {
            "observation": {
                "type": "Kinematics",
                "vehicles_count": max(1, other_vehicles + 1),
                "features": ["presence", "x", "y", "vx", "vy", "cos_h", "sin_h"],
                "absolute": True,
                "normalize": False,
            },
            "action": {
                "type": "ContinuousAction",
                "longitudinal": True,
                "lateral": True,
                "dynamical": True,
            },
            "controlled_vehicles": 1,
            "other_vehicles": other_vehicles,
            "duration": duration,
            "simulation_frequency": 15,
            "policy_frequency": 15,
            "screen_width": 1000,
            "screen_height": 800,
            "centering_position": [0.5, 0.5],
            "scaling": 7.0,
            "show_trajectories": True,
            "terminate_off_road": True,
        }
    )
    return env


def run(args: argparse.Namespace) -> None:
    render_mode = "human" if not args.no_render else None
    env = make_env(args.env_id, render_mode, args.duration, args.traffic)
    shared = SharedControls(args)
    if args.gui:
        start_control_panel(shared)

    controller = CircuitPIDController(
        target_speed=args.target_speed,
        speed_pid=PID(args.speed_kp, args.speed_ki, args.speed_kd),
        lateral_pid=PID(args.lateral_kp, args.lateral_ki, args.lateral_kd),
        heading_gain=args.heading_gain,
        cruise_throttle=args.cruise_throttle,
        curve_lookahead=args.curve_lookahead,
        rolling_drag=args.rolling_drag,
        aero_drag=args.aero_drag,
        corner_drag=args.corner_drag,
        brake_deadband=args.brake_deadband,
        pedal_smoothing=args.pedal_smoothing,
    )

    dt = 1.0 / env.unwrapped.config["policy_frequency"]
    obs, info = env.reset(seed=args.seed)
    controller.reset()

    total_reward = 0.0
    for step in range(args.duration * env.unwrapped.config["policy_frequency"]):
        params, reset_requested, quit_requested = shared.snapshot()
        controller.update_params(params)
        if reset_requested:
            controller.reset()
        if quit_requested:
            break

        command = controller.act(env, dt)
        action = command.as_highway_env_action()
        obs, reward, terminated, truncated, info = env.step(action)
        total_reward += reward

        vehicle = env.unwrapped.vehicle
        _, lateral = vehicle.lane.local_coordinates(vehicle.position)
        shared.update_metrics(
            step=float(step),
            speed=float(vehicle.speed),
            speed_setpoint=controller.speed_setpoint,
            curve_severity=controller.curve_severity,
            virtual_drag=controller.virtual_drag,
            lateral_error=float(lateral),
            accelerator=command.accelerator,
            brake=command.brake,
            steering=command.steering,
        )

        if args.log_every and step % args.log_every == 0:
            print(
                f"step={step:04d} speed={vehicle.speed:5.2f}m/s "
                f"setpoint={controller.speed_setpoint:5.2f}m/s "
                f"curve={controller.curve_severity:.2f}rad "
                f"drag={controller.virtual_drag:.3f} "
                f"lateral_error={lateral:6.2f}m "
                f"accelerator={command.accelerator:.3f} "
                f"brake={command.brake:.3f} "
                f"steering={command.steering:.3f}"
            )

        if render_mode == "human":
            env.render()
            time.sleep(dt * params["slowdown"])

        if terminated or truncated:
            break

    print(f"finished: steps={step + 1}, total_reward={total_reward:.2f}")
    env.close()


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="PID-controlled car on a highway-env racetrack circuit."
    )
    parser.add_argument(
        "--env-id",
        default="racetrack-v1",
        choices=[
            "racetrack-v0",
            "racetrack-v1",
            "racetrack-large-v0",
            "racetrack-large-v1",
            "racetrack-oval-v0",
            "racetrack-oval-v1",
        ],
        help="Racetrack variant.",
    )
    parser.add_argument("--target-speed", type=float, default=8.0, help="Target speed in m/s.")
    parser.add_argument("--duration", type=int, default=90, help="Episode duration in seconds.")
    parser.add_argument("--traffic", type=int, default=0, help="Number of other vehicles.")
    parser.add_argument("--seed", type=int, default=7)
    parser.add_argument("--no-render", action="store_true", help="Run headless.")
    parser.add_argument("--gui", action=argparse.BooleanOptionalAction, default=True)
    parser.add_argument("--slowdown", type=float, default=0.8, help="Rendering delay multiplier.")
    parser.add_argument("--log-every", type=int, default=15, help="Print every N control steps; 0 disables.")

    parser.add_argument("--speed-kp", type=float, default=0.35)
    parser.add_argument("--speed-ki", type=float, default=0.03)
    parser.add_argument("--speed-kd", type=float, default=0.02)

    parser.add_argument("--lateral-kp", type=float, default=0.25)
    parser.add_argument("--lateral-ki", type=float, default=0.00)
    parser.add_argument("--lateral-kd", type=float, default=0.06)
    parser.add_argument("--heading-gain", type=float, default=1.3)
    parser.add_argument("--cruise-throttle", type=float, default=0.02)
    parser.add_argument("--curve-lookahead", type=float, default=35.0)
    parser.add_argument("--rolling-drag", type=float, default=0.06)
    parser.add_argument("--aero-drag", type=float, default=0.08)
    parser.add_argument("--corner-drag", type=float, default=0.35)
    parser.add_argument("--brake-deadband", type=float, default=0.8)
    parser.add_argument("--pedal-smoothing", type=float, default=0.45)
    return parser.parse_args()


if __name__ == "__main__":
    run(parse_args())

結果はこちら。
v3_1.png

v3_2.png

ハンドルと文字が被っていますが、だいぶいい感じになりましたね。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?