Files
ParkingRobot/data_process/旧版控制器轨迹测试处理/plot_trajectory_comparison.py
T

921 lines
29 KiB
Python

"""绘制理想轨迹与Detour实际轨迹对比图。
不传CSV路径时,默认处理本脚本目录下的全部CSV文件。
本文件也提供其余三个绘图脚本共用的数据预处理函数。
"""
from __future__ import annotations
import argparse
import re
from pathlib import Path
from typing import Any
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy.signal import savgol_filter
SCRIPT_DIR = Path(__file__).resolve().parent
REQUIRED_COLUMNS = {
"ElapsedSeconds",
"TrajectoryName",
"DetourX",
"DetourY",
"DetourTheta",
"CommandSpeed",
"CommandAngularSpeed",
"ReferenceStartX",
"ReferenceStartY",
"ReferenceEndX",
"ReferenceEndY",
"ReferenceSpeed",
}
def configure_matplotlib() -> None:
"""配置中文字体和图片输出风格。"""
matplotlib.rcParams["font.sans-serif"] = [
"Microsoft YaHei",
"SimHei",
"Arial Unicode MS",
"DejaVu Sans",
]
matplotlib.rcParams["axes.unicode_minus"] = False
matplotlib.rcParams["figure.dpi"] = 120
def _odd_window_length(
sample_count: int,
sample_interval: float,
window_seconds: float,
polynomial_order: int = 2,
) -> int | None:
"""计算不超过数据长度的Savitzky-Golay奇数窗口。"""
requested = max(
polynomial_order + 2,
int(round(window_seconds / sample_interval)),
)
if requested % 2 == 0:
requested += 1
maximum = sample_count if sample_count % 2 == 1 else sample_count - 1
window = min(requested, maximum)
minimum = polynomial_order + 2
if minimum % 2 == 0:
minimum += 1
return window if window >= minimum else None
def wrap_degrees(angle_degrees: np.ndarray) -> np.ndarray:
"""将角度差归一化到[-180°, 180°)。"""
return (angle_degrees + 180.0) % 360.0 - 180.0
def build_complete_s_curve(
start: np.ndarray,
end: np.ndarray,
offset_mm: float,
samples_per_segment: int = 120,
) -> tuple[np.ndarray, np.ndarray]:
"""重建测试使用的三段三次贝塞尔完整S曲线及各点切线航向。"""
line = end - start
length = float(np.linalg.norm(line))
if length <= 1e-6:
raise ValueError("S型曲线的起点和终点不能重合。")
forward = line / length
left = np.array([-forward[1], forward[0]])
controls = [
np.array([
[0.0, 0.0],
[length / 12.0, 0.0],
[length / 6.0, offset_mm],
[length * 0.25, offset_mm],
]),
np.array([
[length * 0.25, offset_mm],
[length / 3.0, offset_mm],
[length * 2.0 / 3.0, -offset_mm],
[length * 0.75, -offset_mm],
]),
np.array([
[length * 0.75, -offset_mm],
[length * 5.0 / 6.0, -offset_mm],
[length * 11.0 / 12.0, 0.0],
[length, 0.0],
]),
]
local_parts: list[np.ndarray] = []
derivative_parts: list[np.ndarray] = []
for index, points in enumerate(controls):
t = np.linspace(0.0, 1.0, samples_per_segment + 1)
if index > 0:
t = t[1:]
one_minus_t = 1.0 - t
local = (
one_minus_t[:, None] ** 3 * points[0]
+ 3.0
* one_minus_t[:, None] ** 2
* t[:, None]
* points[1]
+ 3.0
* one_minus_t[:, None]
* t[:, None] ** 2
* points[2]
+ t[:, None] ** 3 * points[3]
)
derivative = (
3.0
* one_minus_t[:, None] ** 2
* (points[1] - points[0])
+ 6.0
* one_minus_t[:, None]
* t[:, None]
* (points[2] - points[1])
+ 3.0
* t[:, None] ** 2
* (points[3] - points[2])
)
local_parts.append(local)
derivative_parts.append(derivative)
local_points = np.vstack(local_parts)
local_derivatives = np.vstack(derivative_parts)
world_points = (
start
+ local_points[:, 0, None] * forward
+ local_points[:, 1, None] * left
)
world_derivatives = (
local_derivatives[:, 0, None] * forward
+ local_derivatives[:, 1, None] * left
)
headings = np.rad2deg(
np.arctan2(world_derivatives[:, 1], world_derivatives[:, 0])
)
return world_points, headings
def segmented_savgol(
values: np.ndarray,
sample_interval: float,
window_seconds: float,
derivative: int = 0,
polynomial_order: int = 2,
) -> np.ndarray:
"""对含NaN断点的数据逐段执行SG滤波或求导。"""
values = np.asarray(values, dtype=float)
result = np.full_like(values, np.nan)
finite_indices = np.flatnonzero(np.isfinite(values))
if finite_indices.size == 0:
return result
breaks = np.flatnonzero(np.diff(finite_indices) > 1)
starts = np.r_[0, breaks + 1]
ends = np.r_[breaks + 1, finite_indices.size]
for start_index, end_index in zip(starts, ends):
indices = finite_indices[start_index:end_index]
segment = values[indices]
window = _odd_window_length(
len(segment),
sample_interval,
window_seconds,
polynomial_order,
)
if window is not None:
result[indices] = savgol_filter(
segment,
window,
polynomial_order,
deriv=derivative,
delta=sample_interval,
mode="interp",
)
elif derivative == 0:
result[indices] = segment
elif len(segment) >= 2:
result[indices] = np.gradient(segment, sample_interval)
return result
def shade_localization_jump_windows(
axis,
metadata: dict[str, Any],
) -> None:
"""在时间曲线中标记不应参与车辆动力学评价的定位跳变窗口。"""
for index, (start, end) in enumerate(
metadata["jump_exclusion_windows"]
):
axis.axvspan(
start,
end,
color="tab:red",
alpha=0.12,
label="Detour定位跳变排除窗口" if index == 0 else None,
)
def load_and_resample(
csv_path: Path,
frequency_hz: float = 20.0,
filter_window_seconds: float = 0.55,
) -> tuple[pd.DataFrame, dict[str, Any]]:
"""压缩Detour保持帧,检测定位跳变,再分段重采样和平滑。"""
if not np.isfinite(frequency_hz) or frequency_hz <= 0.0:
raise ValueError("重采样频率必须是正有限值。")
raw = pd.read_csv(csv_path)
missing = REQUIRED_COLUMNS.difference(raw.columns)
if missing:
raise ValueError(
f"{csv_path.name}缺少列:{', '.join(sorted(missing))}"
)
numeric_columns = [
"ElapsedSeconds",
"DetourX",
"DetourY",
"DetourTheta",
"CommandSpeed",
"CommandAngularSpeed",
"ReferenceStartX",
"ReferenceStartY",
"ReferenceEndX",
"ReferenceEndY",
"ReferenceSpeed",
]
optional_numeric_columns = [
"CommandAngularSpeedRadPerSecond",
"ReferenceAngularSpeedRadPerSecond",
"ReferenceMotionFrameYawDegrees",
]
numeric_columns.extend(
column
for column in optional_numeric_columns
if column in raw.columns
)
for column in numeric_columns:
raw[column] = pd.to_numeric(raw[column], errors="coerce")
raw = (
raw.dropna(subset=[
"ElapsedSeconds",
"DetourX",
"DetourY",
"DetourTheta",
])
.sort_values("ElapsedSeconds")
.drop_duplicates("ElapsedSeconds", keep="last")
.reset_index(drop=True)
)
if len(raw) < 5:
raise ValueError(f"{csv_path.name}有效数据不足5行。")
time_raw = raw["ElapsedSeconds"].to_numpy(dtype=float)
time_raw = time_raw - time_raw[0]
raw["ElapsedSeconds"] = time_raw
duration = float(time_raw[-1])
sample_interval = 1.0 / frequency_hz
time_uniform = np.arange(
0.0,
duration + sample_interval * 0.5,
sample_interval,
)
def interpolate_command(column: str) -> np.ndarray:
values = raw[column].to_numpy(dtype=float)
return np.interp(time_uniform, time_raw, values)
# 记录器频率高于Detour更新频率,会得到A,A,B,B形式的保持帧。
# 速度估计前先保留真正发生位姿更新的样本。
x_all = raw["DetourX"].to_numpy(dtype=float)
y_all = raw["DetourY"].to_numpy(dtype=float)
theta_all = raw["DetourTheta"].to_numpy(dtype=float)
position_change = np.hypot(np.diff(x_all), np.diff(y_all))
heading_change = np.abs(wrap_degrees(np.diff(theta_all)))
update_mask = np.r_[
True,
(position_change > 1e-6) | (heading_change > 1e-6),
]
updates = raw.loc[update_mask].copy().reset_index(drop=True)
if len(updates) < 3:
raise ValueError(f"{csv_path.name}有效Detour更新点不足3个。")
update_time = updates["ElapsedSeconds"].to_numpy(dtype=float)
update_x = updates["DetourX"].to_numpy(dtype=float)
update_y = updates["DetourY"].to_numpy(dtype=float)
update_theta = updates["DetourTheta"].to_numpy(dtype=float)
update_command_speed = np.abs(
updates["CommandSpeed"].to_numpy(dtype=float)
)
if "CommandAngularSpeedRadPerSecond" in updates.columns:
update_command_angular_rad = np.abs(
updates[
"CommandAngularSpeedRadPerSecond"
].to_numpy(dtype=float)
)
else:
# 旧CSV中的CommandAngularSpeed单位为deg/s。
update_command_angular_rad = np.deg2rad(
np.abs(
updates[
"CommandAngularSpeed"
].to_numpy(dtype=float)
)
)
# 自适应跳变阈值:正常移动允许达到参考位移的3倍并保留15mm余量;
# 低速阶段仍至少允许30mm,防止把普通定位噪声误判为跳变。
update_dt = np.diff(update_time)
update_distance = np.hypot(np.diff(update_x), np.diff(update_y))
expected_distance = (
0.5 *
(update_command_speed[1:] + update_command_speed[:-1]) *
update_dt *
1000.0
)
distance_threshold = np.maximum(
30.0,
expected_distance * 3.0 + 15.0,
)
update_heading_delta = np.abs(
wrap_degrees(np.diff(update_theta))
)
expected_heading_delta = (
0.5 *
(
update_command_angular_rad[1:] +
update_command_angular_rad[:-1]
) *
update_dt *
180.0 / np.pi
)
heading_threshold = np.maximum(
5.0,
expected_heading_delta * 3.0 + 2.0,
)
jump_before_current = (
(update_distance > distance_threshold) |
(update_heading_delta > heading_threshold)
)
jump_at_update = np.r_[False, jump_before_current]
segment_ids = np.cumsum(jump_at_update.astype(int))
jump_events: list[dict[str, float]] = []
for current_index in np.flatnonzero(jump_at_update):
previous_index = current_index - 1
jump_events.append({
"time_seconds": float(update_time[current_index]),
"distance_mm": float(update_distance[previous_index]),
"heading_change_degrees":
float(update_heading_delta[previous_index]),
"before_x_mm": float(update_x[previous_index]),
"before_y_mm": float(update_y[previous_index]),
"after_x_mm": float(update_x[current_index]),
"after_y_mm": float(update_y[current_index]),
})
# 不跨越定位跳变插值。跳变前后之间保留NaN,使轨迹图自然断线,
# 也防止SG滤波把坐标修正涂抹成车辆高速运动。
x_resampled = np.full_like(time_uniform, np.nan)
y_resampled = np.full_like(time_uniform, np.nan)
theta_resampled = np.full_like(time_uniform, np.nan)
update_theta_unwrapped = np.rad2deg(
np.unwrap(np.deg2rad(update_theta))
)
maximum_segment_id = int(segment_ids[-1])
for segment_id in range(maximum_segment_id + 1):
segment_mask = segment_ids == segment_id
segment_time = update_time[segment_mask]
if segment_time.size == 0:
continue
interval_start = (
0.0 if segment_id == 0 else float(segment_time[0])
)
interval_end = (
duration
if segment_id == maximum_segment_id
else float(segment_time[-1])
)
uniform_mask = (
(time_uniform >= interval_start) &
(time_uniform <= interval_end)
)
x_resampled[uniform_mask] = np.interp(
time_uniform[uniform_mask],
segment_time,
update_x[segment_mask],
)
y_resampled[uniform_mask] = np.interp(
time_uniform[uniform_mask],
segment_time,
update_y[segment_mask],
)
theta_resampled[uniform_mask] = np.interp(
time_uniform[uniform_mask],
segment_time,
update_theta_unwrapped[segment_mask],
)
x_filtered = segmented_savgol(
x_resampled,
sample_interval,
filter_window_seconds,
)
y_filtered = segmented_savgol(
y_resampled,
sample_interval,
filter_window_seconds,
)
theta_filtered = segmented_savgol(
theta_resampled,
sample_interval,
filter_window_seconds,
)
exclusion_half_width = max(
0.30,
filter_window_seconds * 0.5,
)
jump_exclusion_windows = [
(
max(0.0, event["time_seconds"] - exclusion_half_width),
min(duration, event["time_seconds"] + exclusion_half_width),
)
for event in jump_events
]
invalid_near_jump = np.zeros(len(time_uniform), dtype=bool)
for start, end in jump_exclusion_windows:
invalid_near_jump |= (
(time_uniform >= start) & (time_uniform <= end)
)
if "CommandAngularSpeedRadPerSecond" in raw.columns:
angular_command_rad = interpolate_command(
"CommandAngularSpeedRadPerSecond"
)
else:
angular_command_rad = np.deg2rad(
interpolate_command("CommandAngularSpeed")
)
frame = pd.DataFrame({
"TimeSeconds": time_uniform,
"DetourXRawMm": x_resampled,
"DetourYRawMm": y_resampled,
"DetourXFilteredMm": x_filtered,
"DetourYFilteredMm": y_filtered,
"DetourThetaUnwrappedDeg": theta_filtered,
"DetourThetaDeg": wrap_degrees(theta_filtered),
"CommandSpeedMps": interpolate_command("CommandSpeed"),
"CommandAngularSpeedRadPerSec":
angular_command_rad,
"InvalidNearLocalizationJump": invalid_near_jump,
})
first = raw.iloc[0]
metadata: dict[str, Any] = {
"csv_path": csv_path,
"trajectory_name": str(first["TrajectoryName"]),
"controller_name": str(first.get("ControllerName", "")),
"trial_number": str(first.get("TrialNumber", "")),
# 蟹行轨迹的运动前向相对车体X轴逆时针偏置90°。
# DetourTheta始终是车体航向,计算航向误差时必须扣除该偏置。
"motion_frame_yaw_degrees": float(
first["ReferenceMotionFrameYawDegrees"]
if (
"ReferenceMotionFrameYawDegrees" in raw.columns
and pd.notna(
first["ReferenceMotionFrameYawDegrees"]
)
)
else (
90.0
if "crab" in (
str(first["TrajectoryName"]) +
str(first.get("ControllerName", ""))
).lower()
else 0.0
)
),
"reference_start_mm": np.array(
[first["ReferenceStartX"], first["ReferenceStartY"]],
dtype=float,
),
"reference_end_mm": np.array(
[first["ReferenceEndX"], first["ReferenceEndY"]],
dtype=float,
),
"reference_speed_mps": float(first["ReferenceSpeed"]),
"reference_angular_speed_rad_per_second": float(
first.get(
"ReferenceAngularSpeedRadPerSecond",
0.0,
)
),
# 圆弧构造时使用了测试开始处Detour航向,因此这里取首帧航向。
"start_heading_degrees": float(first["DetourTheta"]),
"sample_interval_seconds": sample_interval,
"filter_window_seconds": filter_window_seconds,
"raw_sample_count": len(raw),
"detour_update_count": len(updates),
"held_sample_count": int(len(raw) - len(updates)),
"localization_jump_events": jump_events,
"jump_exclusion_windows": jump_exclusion_windows,
}
return frame, metadata
def build_reference(
frame: pd.DataFrame,
metadata: dict[str, Any],
) -> dict[str, np.ndarray | float | str]:
"""根据CSV元数据建立直线、圆弧、完整S曲线或原地自转参考及误差。"""
trajectory_name = str(metadata["trajectory_name"])
start = np.asarray(metadata["reference_start_mm"], dtype=float)
end = np.asarray(metadata["reference_end_mm"], dtype=float)
motion_frame_yaw_degrees = float(
metadata.get("motion_frame_yaw_degrees", 0.0)
)
actual = frame[
["DetourXFilteredMm", "DetourYFilteredMm"]
].to_numpy(dtype=float)
actual_heading = frame["DetourThetaUnwrappedDeg"].to_numpy(dtype=float)
radius_match = re.search(
r"LeftArc(?P<sweep>[0-9.]+)_R(?P<radius>[0-9.]+)mm",
trajectory_name,
flags=re.IGNORECASE,
)
if radius_match:
radius = float(radius_match.group("radius"))
sweep_degrees = float(radius_match.group("sweep"))
start_body_heading = float(metadata["start_heading_degrees"])
start_motion_heading = (
start_body_heading + motion_frame_yaw_degrees
)
heading_radians = np.deg2rad(start_motion_heading)
center = start + radius * np.array(
[-np.sin(heading_radians), np.cos(heading_radians)]
)
start_radial_degrees = start_motion_heading - 90.0
radial = actual - center
distance_to_center = np.linalg.norm(radial, axis=1)
radial_angle_degrees = np.rad2deg(
np.arctan2(radial[:, 1], radial[:, 0])
)
radial_angle_radians = np.deg2rad(radial_angle_degrees)
reference_points = center + radius * np.column_stack([
np.cos(radial_angle_radians),
np.sin(radial_angle_radians),
])
# 对逆时针圆弧,正横向误差表示车辆位于轨迹左侧(圆内侧)。
lateral_error = radius - distance_to_center
reference_motion_heading = radial_angle_degrees + 90.0
reference_heading = (
reference_motion_heading - motion_frame_yaw_degrees
)
heading_error = wrap_degrees(
actual_heading - reference_heading
)
plot_angles = np.deg2rad(
np.linspace(
start_radial_degrees,
start_radial_degrees + sweep_degrees,
361,
)
)
ideal_plot = center + radius * np.column_stack([
np.cos(plot_angles),
np.sin(plot_angles),
])
return {
"kind": "left_arc",
"ideal_plot_mm": ideal_plot,
"reference_points_mm": reference_points,
"reference_heading_degrees": reference_heading,
"reference_motion_heading_degrees":
reference_motion_heading,
"lateral_error_mm": lateral_error,
"heading_error_degrees": heading_error,
"center_mm": center,
"radius_mm": radius,
}
s_curve_match = re.search(
r"SCurve(?P<length>[0-9.]+)m_A(?P<offset>[0-9.]+)mm",
trajectory_name,
flags=re.IGNORECASE,
)
if s_curve_match:
offset_mm = float(s_curve_match.group("offset"))
ideal_plot, ideal_heading = build_complete_s_curve(
start,
end,
offset_mm,
)
delta = actual[:, np.newaxis, :] - ideal_plot[np.newaxis, :, :]
nearest_indices = np.argmin(
np.sum(delta * delta, axis=2),
axis=1,
)
reference_points = ideal_plot[nearest_indices]
reference_motion_heading = ideal_heading[nearest_indices]
reference_heading = (
reference_motion_heading - motion_frame_yaw_degrees
)
heading_radians = np.deg2rad(reference_motion_heading)
left_normals = np.column_stack([
-np.sin(heading_radians),
np.cos(heading_radians),
])
lateral_error = np.sum(
(actual - reference_points) * left_normals,
axis=1,
)
heading_error = wrap_degrees(
actual_heading - reference_heading
)
return {
"kind": "s_curve",
"ideal_plot_mm": ideal_plot,
"reference_points_mm": reference_points,
"reference_heading_degrees": reference_heading,
"reference_motion_heading_degrees":
reference_motion_heading,
"lateral_error_mm": lateral_error,
"heading_error_degrees": heading_error,
"offset_mm": offset_mm,
}
line = end - start
length = float(np.linalg.norm(line))
if length <= 1e-6:
rotation_match = re.search(
r"Rotate(?P<angle>[+-]?[0-9.]+)",
trajectory_name,
flags=re.IGNORECASE,
)
if rotation_match:
relative_angle_degrees = float(
rotation_match.group("angle")
)
target_heading_degrees = (
float(metadata["start_heading_degrees"]) +
relative_angle_degrees
)
reference_points = np.repeat(
start[np.newaxis, :],
len(frame),
axis=0,
)
position_drift = np.linalg.norm(
actual - start,
axis=1,
)
reference_heading = np.full(
len(frame),
target_heading_degrees,
)
heading_error = wrap_degrees(
actual_heading - reference_heading
)
ideal_plot = np.repeat(
start[np.newaxis, :],
2,
axis=0,
)
return {
"kind": "in_place_rotation",
"ideal_plot_mm": ideal_plot,
"reference_points_mm": reference_points,
"reference_heading_degrees": reference_heading,
# 对原地自转,该字段表示偏离初始旋转中心的距离。
"lateral_error_mm": position_drift,
"heading_error_degrees": heading_error,
"rotation_center_mm": start,
"relative_angle_degrees": relative_angle_degrees,
"target_heading_degrees": target_heading_degrees,
}
raise ValueError(
f"{trajectory_name}无法识别为圆弧,且参考直线长度为0。"
)
tangent = line / length
left_normal = np.array([-tangent[1], tangent[0]])
displacement = actual - start
progress = np.clip(displacement @ tangent, 0.0, length)
reference_points = start + np.outer(progress, tangent)
lateral_error = (actual - reference_points) @ left_normal
reference_motion_heading_scalar = np.rad2deg(
np.arctan2(tangent[1], tangent[0])
)
reference_heading_scalar = (
reference_motion_heading_scalar -
motion_frame_yaw_degrees
)
reference_heading = np.full(
len(frame),
reference_heading_scalar,
)
heading_error = wrap_degrees(
actual_heading - reference_heading
)
ideal_plot = np.linspace(start, end, 361)
return {
"kind": "line",
"ideal_plot_mm": ideal_plot,
"reference_points_mm": reference_points,
"reference_heading_degrees": reference_heading,
"reference_motion_heading_degrees": np.full(
len(frame),
reference_motion_heading_scalar,
),
"lateral_error_mm": lateral_error,
"heading_error_degrees": heading_error,
}
def discover_csv_files(arguments: list[str]) -> list[Path]:
"""解析命令行CSV;未指定时使用脚本目录下全部CSV。"""
if arguments:
files = [Path(item).expanduser().resolve() for item in arguments]
else:
files = sorted(SCRIPT_DIR.glob("*.csv"))
if not files:
raise FileNotFoundError("没有找到可处理的CSV文件。")
return files
def output_path(
csv_path: Path,
output_directory: str | None,
suffix: str,
) -> Path:
"""构造图片输出路径并创建目录。"""
directory = (
Path(output_directory).expanduser().resolve()
if output_directory
else csv_path.parent / "plots"
)
directory.mkdir(parents=True, exist_ok=True)
return directory / f"{csv_path.stem}_{suffix}.png"
def plot_trajectory(
csv_path: Path,
frequency_hz: float,
filter_window_seconds: float,
output_directory: str | None,
show: bool,
) -> Path:
"""生成单份CSV的理想/实际轨迹对比图。"""
frame, metadata = load_and_resample(
csv_path,
frequency_hz,
filter_window_seconds,
)
reference = build_reference(frame, metadata)
actual_x_m = frame["DetourXFilteredMm"].to_numpy() / 1000.0
actual_y_m = frame["DetourYFilteredMm"].to_numpy() / 1000.0
ideal_m = np.asarray(reference["ideal_plot_mm"]) / 1000.0
fig, ax = plt.subplots(figsize=(8.0, 7.0))
ax.plot(
ideal_m[:, 0],
ideal_m[:, 1],
"--",
linewidth=2.2,
label="理想轨迹",
)
ax.plot(
actual_x_m,
actual_y_m,
linewidth=1.8,
label="Detour实际轨迹(滤波后)",
)
if reference["kind"] == "in_place_rotation":
ax.scatter(
[ideal_m[0, 0]],
[ideal_m[0, 1]],
marker="*",
s=100,
label="理想旋转中心",
zorder=5,
)
else:
ax.scatter(
[ideal_m[0, 0]],
[ideal_m[0, 1]],
marker="o",
s=55,
label="起点",
zorder=5,
)
ax.scatter(
[ideal_m[-1, 0]],
[ideal_m[-1, 1]],
marker="x",
s=65,
label="终点",
zorder=5,
)
for event_index, event in enumerate(
metadata["localization_jump_events"]
):
before = np.array([
event["before_x_mm"],
event["before_y_mm"],
]) / 1000.0
after = np.array([
event["after_x_mm"],
event["after_y_mm"],
]) / 1000.0
ax.scatter(
[before[0], after[0]],
[before[1], after[1]],
marker="x",
color="tab:red",
s=55,
zorder=6,
label="Detour定位跳变前/后"
if event_index == 0 else None,
)
ax.annotate(
f"定位跳变 {event['distance_mm']:.1f} mm\n"
f"t={event['time_seconds']:.2f} s",
xy=(after[0], after[1]),
xytext=(8, 8),
textcoords="offset points",
color="tab:red",
fontsize=9,
)
ax.set_aspect("equal", adjustable="box")
ax.set_xlabel("世界坐标 X / m")
ax.set_ylabel("世界坐标 Y / m")
ax.set_title(
f"理想轨迹与实际轨迹对比\n"
f"{metadata['controller_name']} - "
f"{metadata['trajectory_name']}"
)
ax.grid(True, alpha=0.3)
ax.legend()
fig.tight_layout()
destination = output_path(
csv_path,
output_directory,
"trajectory_comparison",
)
fig.savefig(destination, dpi=300, bbox_inches="tight")
if show:
plt.show()
plt.close(fig)
print(
f"{csv_path.name}: 原始采样{metadata['raw_sample_count']}帧,"
f"有效Detour更新{metadata['detour_update_count']}帧,"
f"保持重复{metadata['held_sample_count']}帧,"
f"定位跳变{len(metadata['localization_jump_events'])}次"
)
return destination
def main() -> None:
configure_matplotlib()
parser = argparse.ArgumentParser(
description="绘制理想轨迹与Detour实际轨迹对比图。"
)
parser.add_argument("files", nargs="*", help="一个或多个CSV文件")
parser.add_argument("--frequency", type=float, default=20.0)
parser.add_argument("--window", type=float, default=0.55)
parser.add_argument("--output-dir")
parser.add_argument("--show", action="store_true")
args = parser.parse_args()
for csv_path in discover_csv_files(args.files):
destination = plot_trajectory(
csv_path,
args.frequency,
args.window,
args.output_dir,
args.show,
)
print(f"已生成:{destination}")
if __name__ == "__main__":
main()