"""
ts_pd_control
Theil-Sen 直線近似による PD 制御モジュール
行中心点に Theil-Sen 直線をフィッティングし,
位置偏差・傾き・微分項から操舵量を算出する
"""
import time
from dataclasses import dataclass
import numpy as np
from common import config
from common.steering.base import SteeringBase, SteeringOutput
from common.vision.fitting import theil_sen_fit
from common.vision.line_detector import (
ImageParams,
LineDetectResult,
detect_line,
reset_valley_tracker,
)
@dataclass
class TsPdParams:
"""Theil-Sen PD 制御のパラメータ
Attributes:
kp: 位置偏差ゲイン
kh: 傾き(Theil-Sen slope)ゲイン
kd: 微分ゲイン
max_steer_rate: 1フレームあたりの最大操舵変化量
max_throttle: 直線での最大速度
speed_k: 傾きベースの減速係数
"""
kp: float = 0.5
kh: float = 0.3
kd: float = 0.1
max_steer_rate: float = 0.1
max_throttle: float = 0.4
speed_k: float = 2.0
class TsPdControl(SteeringBase):
"""Theil-Sen 直線近似による PD 制御クラス
行中心点から Theil-Sen 直線近似を行い,
画像下端での位置偏差と直線の傾きから PD 制御で操舵量を計算する
"""
def __init__(
self,
params: TsPdParams | None = None,
image_params: ImageParams | None = None,
) -> None:
self.params: TsPdParams = (
params or TsPdParams()
)
self.image_params: ImageParams = (
image_params or ImageParams()
)
self._prev_error: float = 0.0
self._prev_time: float = 0.0
self._prev_steer: float = 0.0
self._last_result: LineDetectResult | None = None
def compute(
self, frame: np.ndarray,
) -> SteeringOutput:
"""カメラ画像から Theil-Sen PD 制御で操舵量を計算する
Args:
frame: グレースケールのカメラ画像
Returns:
計算された操舵量
"""
p = self.params
# 線検出
result = detect_line(frame, self.image_params)
self._last_result = result
if not result.detected or result.row_centers is None:
return SteeringOutput(
throttle=0.0, steer=0.0,
)
centers = result.row_centers
# 有効な点(NaN でない行)を抽出
valid = ~np.isnan(centers)
ys = np.where(valid)[0].astype(float)
xs = centers[valid]
if len(ys) < 2:
return SteeringOutput(
throttle=0.0, steer=0.0,
)
# Theil-Sen 直線近似: x = slope * y + intercept
slope, intercept = theil_sen_fit(ys, xs)
center_x = config.FRAME_WIDTH / 2.0
h = len(centers)
# 画像下端での位置偏差
bottom_x = slope * (h - 1) + intercept
position_error = (center_x - bottom_x) / center_x
# 操舵量: P 項(位置偏差)+ Heading 項(傾き)
error = p.kp * position_error + p.kh * slope
# 時間差分の計算
now = time.time()
dt = (
now - self._prev_time
if self._prev_time > 0
else 0.033
)
dt = max(dt, 0.001)
# D 項(微分項)
derivative = (error - self._prev_error) / dt
steer = error + p.kd * derivative
# 操舵量のクランプ
steer = max(-1.0, min(1.0, steer))
# レートリミッター
delta = steer - self._prev_steer
max_delta = p.max_steer_rate
delta = max(-max_delta, min(max_delta, delta))
steer = self._prev_steer + delta
# 速度制御(傾きベース)
throttle = p.max_throttle - p.speed_k * abs(slope)
throttle = max(0.0, throttle)
# 状態の更新
self._prev_error = error
self._prev_time = now
self._prev_steer = steer
return SteeringOutput(
throttle=throttle, steer=steer,
)
def reset(self) -> None:
"""内部状態をリセットする"""
self._prev_error = 0.0
self._prev_time = 0.0
self._prev_steer = 0.0
self._last_result = None
reset_valley_tracker()
@property
def last_detect_result(
self,
) -> LineDetectResult | None:
"""直近の線検出結果を取得する"""
return self._last_result