第一次用 MATLAB 的驾驶场景设计器搭弯道,我在屏幕前坐了整整一个下午,最后卡住我的不是代码语法,也不是工具箱版本,而是一个特别朴素的问题:这条弯的曲率半径到底该填多少。那时候我以为自己在做自动驾驶弯道仿真,实际上是在做一件更基础的事——把一条现实里的路,翻译成机器能理解的几何。翻译错了,后面挂多少传感器、跑多复杂的控制算法,全是在错误的地基上盖楼。
这篇东西想聊的就是这件事:MATLAB 驾驶场景设计器(Driving Scenario Designer)到底怎么在几分钟内搭出一条能跑通闭环的弯道场景,哪些参数是必须先算的,哪些步骤是可以直接抄的,以及我在这个过程中反复踩到的那些坑。它不是一篇功能说明书,工具箱的官方文档已经把这活儿干得挺好;我更想讲清楚的是,一个真正要用它做自动驾驶算法验证的人,会怎么下手、会在哪里停下来想一想。
适合谁看?如果你刚拿到 Automated Driving Toolbox,想快速搞出一个弯道场景来验证横向控制或者车道线检测;或者你已经在用场景设计器,但总觉得搭出来的路"看着像那么回事,跑起来不对劲"——那这篇里的参数推导和踩坑记录应该能省你不少时间。代码和步骤都是可以复现的,参数我也尽量给全,你改个半径就能生成自己的弯。
1. 弯道场景的难点不在建模,而在"曲率怎么给"
1.1 直线场景跑通,弯道上却开始画龙
几乎所有横向控制的算法在直线场景里都是"表现良好"的。你随便写个 PID,横向误差曲线平滑得像教科书插图,看着特别有成就感。然后你把场景一换成弯道,同一套参数,车开始画龙——入弯时切内侧,出弯时甩到外车道,误差曲线像心电图。这时候很多人的第一反应是调增益,PID 参数调了一轮又一轮,最后发现根本问题不在控制器,而在场景本身:曲率是突变的。
现实中的道路在直线和圆弧之间一定有一段缓和曲线,曲率从 0 连续过渡到 1/R。而手搓场景的人,往往直接拿两段圆弧或者"直线+圆弧"硬拼,曲率在拼接点上一秒钟从 0 跳到 0.02,是个阶跃信号。控制器看到一个阶跃曲率,它的响应必然是一个过冲,你再怎么调增益也压不平——这是激励本身的问题,不是系统的问题。
所以弯道场景设计的第一原则是:先想清楚这条弯的曲率在空间上是怎么分布的,而不是先拖鼠标画一条看起来像弯的线。这个顺序颠倒了,后面全是返工。
1.2 场景设计器把道路定义压缩成了一根中心线
理解 MATLAB 这个工具的工作模型,比记住按钮位置重要得多。整个驾驶场景设计器里,道路的核心定义只有一个东西:道路中心线(road centers),一串有序的三维坐标点。车道边界不是独立定义的对象,而是从中心线按车道宽度向两侧"长"出来的。
这个设计带来两个直接后果,都是好事也是坑。好事是建模快——你只要给出中心线的点列和车道宽度,双向两车道、单向三车道,一行lanespec就出来了,左右边界自动生成,曲率也是从点列里算出来的。坑在于,曲率的精度完全取决于你给的点的密度。给点太稀,两点之间被当成直线段处理,你画出来的"圆弧"实际上是个多边形;给点太密,场景文件变大、仿真变慢,而且很多点在同一条直线上,纯属浪费。
所以这里的核心矛盾就清楚了:中心线采样密度,是弯道场景设计里唯一真正需要动脑子的参数。其余的车道数、车道宽度、坡度,都是查规范和量尺寸的事。
另外一点值得提前说清楚:场景设计器里的道路是一个独立的几何对象,车辆是另一个独立的参与者对象,两者之间的关联是通过"车辆被放在道路网格上"这个动作隐含建立的。所以一条路搭完,车放上去动不了或者报错说位置不在路上,八成不是车的参数问题,是道路中心线哪里断开了或者重叠了。我在第 6 节会细说这个坑。
2. 曲率半径、速度与采样密度:三个必须先算清楚的数
2.1 用横向加速度反推这条弯最多能跑多快
搭场景之前,先在纸上(或者命令行里)把这三个数算出来,能省掉后面 80% 的反复调试。
第一个数是曲率半径 R。它决定的不是"弯看起来够不够弯",而是"这条弯上我的车最多能开多快"。物理关系很干净:
a_lat = v² / R其中a_lat是横向加速度。乘客舒适性的经验阈值通常在 2.5 m/s² 左右,稍激进一点到 3.5 m/s²,再往上普通乘员就会明显感到被甩。反过来解出速度v = sqrt(a_lat × R),就有了下面这张表:
| 曲率半径 R | 横向加速度 2.5 m/s² 对应车速 | 横向加速度 3.5 m/s² 对应车速 | 典型对应场景 |
|---|---|---|---|
| 20 m | 7.1 m/s(约 25 km/h) | 8.4 m/s(约 30 km/h) | 停车场、园区道路 |
| 50 m | 11.2 m/s(约 40 km/h) | 13.2 m/s(约 47 km/h) | 城市次干道 |
| 125 m | 17.7 m/s(约 64 km/h) | 20.9 m/s(约 75 km/h) | 城市快速路 |
| 300 m | 27.4 m/s(约 99 km/h) | 32.4 m/s(约 117 km/h) | 高速匝道、平缓弯 |
这张表怎么用?如果你的场景是"城市道路右转",R 取 20 到 30 米比较真实,速度就按 25 km/h 左右设;如果你想验证 80 km/h 下的车道保持能力,那 R 至少得给到 200 米以上,否则车在物理上就不可能贴着车道走,仿真结果没有意义。我见过有人用 R=30 的弯去测 100 km/h 的车道保持,车飞出去之后怪算法不行,这就属于场景设计上的错误归因。
反过来说,如果你的控制算法在"物理上不可能"的场景里表现差,这不叫 bug,这叫正确行为。先算出边界,再判断结果是否合理。
2.2 采样点密度按弦高误差算,别凭感觉
第二个数是采样密度。很多人是"看着顺眼就行",在弧上均匀撒几十个点。这样做的风险是:小半径弯可能采样够密,大半径弯反而采样太稀——因为同样的角度步长,半径越大,弧长越长,弦和弧之间的偏差越大。
正确的算法是按弦高误差(sagitta)来定步长。用弦替代弧,最大偏差出现在弧的中点,公式是:
e = R × (1 - cos(Δθ / 2))反解出可接受的角步长:
Δθ = 2 × arccos(1 - e / R)举个具体的数。设 R = 50 m,要求弦高误差不超过 1 厘米(这个精度对车道线检测评估足够了):
cos(Δθ/2) = 1 - 0.01/50 = 0.9998 Δθ/2 ≈ 0.02 rad Δθ ≈ 0.04 rad ≈ 2.29°一个 90° 的弯(π/2 ≈ 1.5708 rad),需要1.5708 / 0.04 ≈ 40段,也就是 41 个点。有意思的是,如果把 R 换成 300 m,同样的 1 厘米误差要求下,Δθ ≈ 0.0163 rad ≈ 0.93°,90° 弯需要接近 96 段。半径越大,需要的点反而越多——这跟直觉相反,但道理很简单:绝对误差要求固定时,弧的"平缓程度"按半径放大,弦需要在更长的弧上贴住它。
这个结论直接推翻了很多人的默认做法。所以我在脚本里从来不是手写点列,而是写一个小函数按误差目标自动生成:
function pts = arcSamples(R, thetaTotal, chordTol, varargin) % ARCSAMPLES 按弦高误差目标生成圆弧采样点(局部坐标,起点在原点) % R : 曲率半径 (m) % thetaTotal : 圆弧总转角 (rad) % chordTol : 允许的最大弦高误差 (m) dTheta = 2 * acos(max(-1, min(1, 1 - chordTol / R))); n = max(3, ceil(thetaTotal / dTheta) + 1); th = linspace(0, thetaTotal, n)'; pts = [R * sin(th), R * (1 - cos(th)), zeros(n, 1)]; end注意这里用的是"起点在原点、初始朝向沿 +X、向左转"的局部坐标系,y = R(1-cosθ)保证起点 y 坐标为 0。这样生成的弧段可以直接平移旋转拼到别的路段上,拼接的时候不容易算错。
2.3 缓和曲线缺失时会发生什么
刚才提到曲率阶跃的问题。严格来说,真实道路在直线和圆曲线之间有一段回旋线(clothoid),曲率随弧长线性变化。在场景设计器里精确构造回旋线是可以做的,但很多人会觉得麻烦而跳过。跳过之后会怎样?
如果你的目的是验证感知算法(车道线检测、曲率估计),跳过缓和曲线问题不大,因为感知模块看的是图像或点云,曲率的微小阶跃它未必敏感。但如果你的目的是验证横向控制,跳过缓和曲线会让你得到一个偏悲观的结论:控制器在入弯点的超调量会比真实情况大,你可能因此把增益调得过软,反而牺牲了弯中的跟踪精度。
一个折中的做法是用一小段低阶多项式做过渡。比如在直线末端和圆弧起点之间插 15 到 20 米,曲率从 0 线性增长到 1/R,用参数方程近似:
function pts = clothoidApprox(R, L, n) % CLOTHOIDAPPROX 用曲率线性增长近似一段缓和曲线 % R : 目标曲率半径 (m) % L : 缓和曲线长度 (m) % n : 采样点数 s = linspace(0, L, n)'; kappa = s / (R * L); % 曲率从 0 线性增到 1/R theta = cumtrapz(s, kappa); % 朝向角积分 x = cumtrapz(s, cos(theta)); y = cumtrapz(s, sin(theta)); pts = [x, y, zeros(n, 1)]; end这段代码用累积梯形积分把曲率积成朝向、再积成坐标,是很经典的数值做法。精度足够做场景用,缺点是cumtrapz在点数很少时误差偏大,建议 n 给到 50 以上。有了这个函数,"直线—缓和曲线—圆弧—缓和曲线—直线"的标准弯道结构就能拼出来了,曲率连续,控制器的响应也会正常得多。
3. 五分钟搭完一条 90 度弯:界面操作与等价脚本
3.1 界面上的七步操作
说五分钟搞定,前提是参数已经在第 2 节算好了。真正在界面里操作,流程是这样的:
- 命令行输入
drivingScenarioDesigner打开应用,新建一个空白场景。 - 左侧工具栏选Road,在画布上依次点击中心线的关键点。这里的关键技巧是:不要靠手点去凑圆弧,先点三个点大致定出直线段、弯心、出弯方向,后面再用脚本替换成精确点列。
- 选中道路,在右侧属性面板把Lanes设成
[1 1](左右各一条),Width设成[3.5 3.5]。宽度数组的顺序是从最左车道到最右车道,这个顺序搞反了在不对称场景里会很难查。 - 切到Actors页,添加一辆车(ClassID 通常用 1 表示轿车),把它拖到道路起点附近。
- 给这辆车加轨迹:在轨迹编辑里沿中心线点几个途经点,或者直接在属性里填 waypoints。途经点不要只放起终点两个,弯道至少要放 5 个以上,否则中间会被当成直线插值。
- 设置速度。图形界面里可以给每个途经点单独设速度,也可以给一个恒定值。
- 点Run预览。如果车在弯里明显切内道或者出界,回到第 2 节检查半径和速度是否匹配。
界面操作的好处是所见即所得,缺点是点出来的中心线精度有限,而且不可复现——换个同事打开,点出来的点就不一样。所以我的习惯是:界面只用来快速验证想法,最终交付一律用脚本。
3.2 一份可以直接跑的完整脚本
下面这份脚本,输入是半径和转角,输出是一个可以直接plot、可以跑、可以导出的场景对象。参数按 R=50 m、90° 弯、双向两车道来设,你可以直接改前几行。
%% 弯道场景快速构建脚本 clear; clc; close all; % ---------------- 可调参数 ---------------- R = 50; % 弯道曲率半径 (m) thetaDeg = 90; % 圆弧转角 (度) laneWidth = 3.5; % 单车道宽度 (m) entryLen = 40; % 入弯前直线段长度 (m) exitLen = 40; % 出弯后直线段长度 (m) chordTol = 0.01; % 弦高误差容限 (m) vTarget = 11; % 车辆目标速度 (m/s) sampleTime = 0.05; % 场景采样周期 (s) % ----------------------------------------- thetaTotal = deg2rad(thetaDeg); % 1) 分段生成中心线(局部坐标) entryPts = [linspace(-entryLen, 0, 20)', zeros(20, 1), zeros(20, 1)]; arcPts = arcSamples(R, thetaTotal, chordTol); % 圆弧局部坐标 -> 世界坐标:旋转使终点朝向正确 % arcSamples 生成时起点在原点、初始朝向 +X,需平移到 entryPts 末端 arcPts(:,1) = arcPts(:,1) + entryPts(end,1); arcPts(:,2) = arcPts(:,2) + entryPts(end,2); % 出弯段方向:圆弧终点切向 endHeading = thetaTotal; % 逆时针转 thetaTotal 后的朝向 exitDir = [cos(endHeading), sin(endHeading)]; exitStart = arcPts(end, 1:2); exitPts = exitStart + (0:19)'/19 * exitLen * exitDir; exitPts = [exitPts, zeros(20, 1)]; % 2) 拼接并去重(避免零长度段) roadCenters = [entryPts; arcPts(2:end,:); exitPts(2:end,:)]; % 3) 创建场景与道路 scenario = drivingScenario('SampleTime', sampleTime, 'StopTime', 25); road(scenario, roadCenters, ... 'Lanes', lanespec([1 1], 'Width', [laneWidth laneWidth]), ... 'Name', 'Curve_R50'); % 4) 车辆与轨迹 ego = vehicle(scenario, 'ClassID', 1, ... 'Length', 4.5, 'Width', 1.8, 'Height', 1.5, ... 'Position', roadCenters(1,:) + [0 0 0], ... 'Name', 'EgoCar'); % 用中心线上的点作为途经点,速度给恒定值 wp = roadCenters(1:5:end, 1:2); % 抽稀,避免途经点过密 smoothTrajectory(ego, wp, vTarget); % 5) 绘制与回放 figure('Name', 'Curve Scenario'); plot(scenario, 'Waypoints', 'on'); title(sprintf('R = %d m, %d deg curve', R, thetaDeg)); % 6) 可选:回放动画 % record(scenario);几点说明。lanespec([1 1], 'Width', [3.5 3.5])里的[1 1]表示中心线左侧 1 条、右侧 1 条,总共两条车道。smoothTrajectory第三参数既可以是标量(恒定速度),也可以是和途经点等长的速度向量,后者在第 4 节会用来做速度剖面。
arcSamples是这个脚本依赖的辅助函数,就是第 2.2 节给的那一段,放到同目录或者脚本末尾都行。这种"主脚本 + 几个小工具函数"的组织方式,比把所有东西塞进一个巨型脚本要可靠得多,尤其是当你需要同时维护好几个不同半径的弯道时。
3.3 脚本和界面之间怎么来回切换
这两者不是二选一的关系,可以来回倒。脚本里创建好scenario对象之后,直接运行:
drivingScenarioDesigner(scenario);就能带着已有的道路和车辆打开图形界面,继续在界面上加东西。反过来,界面上调好之后,用Export → Export MATLAB Function可以导出成一个返回scenario对象的函数。这个功能我特别推荐,原因有两个:一是它把界面上的操作固化成了可版本管理的代码,二是这个函数可以直接在测试脚本里被调用,做成回归测试的一部分。
我自己的常规流程是这样的:先用脚本生成一个参数化的基础场景,扔进界面里肉眼看一眼形状对不对,如果不对就改脚本里的参数重新生成——而不是在界面上手调。界面只做"检查器",不做"编辑器"。这样每次改的都是可追溯的参数,不会出现"上次那条弯挺好的,但我想不起来怎么点出来的"这种尴尬。
4. 让车真正"沿着弯道走":轨迹与速度剖面的耦合
4.1 起点朝向错一度,弯心就偏出好几米
场景搭好之后,第一个要确认的是车辆的初始位置和朝向。vehicle的Position是三维坐标,Yaw是绕 Z 轴的朝向角,单位是度不是弧度。这个细节坑过太多人:你填pi/2,以为是 90°,实际被当成 1.57°,车头几乎还是朝前,结果整条轨迹都歪着走。
更隐蔽的问题是位置本身。如果车辆初始位置没有严格落在道路中心线上(比如手动填了个[0 0 0],而道路起点在[-40 0 0]),smoothTrajectory会先生成一段从当前位置到第一个途经点的过渡,这段过渡可能直接穿过路肩甚至穿出道路范围,看着就像车"从草地里开出来"。
一个稳妥的做法是直接用道路中心线的第一个点作为初始位置,朝向用前两个点的连线方向算:
p0 = roadCenters(1, :); p1 = roadCenters(2, :); yawDeg = atan2d(p1(2) - p0(2), p1(1) - p0(1)); ego = vehicle(scenario, 'ClassID', 1, ... 'Position', p0, 'Yaw', yawDeg, 'Name', 'EgoCar');atan2d返回的就是角度制,和Yaw的单位直接对齐,省掉一次rad2deg的心算。这个习惯我保持了很久,因为凡是涉及角度单位的地方,人脑出错的概率远高于编译器。
顺带说一句"错一度差多少"。R = 50 m 的弯上,车头方向偏差 1°,开出去 20 米后横向偏差大约是20 × tan(1°) ≈ 0.35 m——已经超过半个车道了。所以初始朝向不是"差不多就行"的参数,得按公式算。
4.2 速度剖面别用阶跃
smoothTrajectory(ego, wp, vTarget)用标量速度时,整条轨迹是匀速的。匀速过弯在物理上就要求横向加速度恒定,也就是v²/R恒定,这对场景本身没毛病,但它会让你的控制器处在"永远稳态"的状态,看不出瞬态响应。
更贴近真实的做法是给一条梯形或 S 形速度剖面:入弯前减速,弯中保持,出弯加速。做法是把速度向量传进去,长度和途经点数一致:
nWp = size(wp, 1); vProfile = ones(nWp, 1) * vTarget; % 前 20% 路程线性降到弯中速度,后 20% 线性升回 vCorner = sqrt(2.5 * R); % 舒适横向加速度对应的弯中速度 nRamp = max(2, round(0.2 * nWp)); vProfile(1:nRamp) = linspace(vTarget, vCorner, nRamp); vProfile(end-nRamp+1:end) = linspace(vCorner, vTarget, nRamp); vProfile(nRamp+1 : end-nRamp) = vCorner; smoothTrajectory(ego, wp, vProfile);这里vCorner = sqrt(2.5 × R)就是第 2.1 节那个公式,R=50 时约 11.2 m/s。这个值不只是个"合理数字",它是场景的物理约束边界——车在弯中以超过这个速度行驶,横向加速度就超出舒适区,你的控制器再优秀也只能靠"不跟车道"来化解。
有个小陷阱:smoothTrajectory对速度向量做平滑,如果你的速度剖面在弯中段是常数、两端是斜坡,平滑后斜坡会略微延长,导致实际的减速点比预期早一点。差异不大,但如果你的测试对减速时机特别敏感(比如测 AEB 与转向的协同),建议把采样点抽稀的间距调小一点,让平滑的影响相对变小。
4.3 横向偏置与车道关联
默认情况下车辆沿中心线走。但很多测试场景需要车在车道中心而不是道路中心开——双向两车道的道路中心线是黄色虚线,车应该走在右侧车道中心,也就是相对中心线偏右laneWidth/2。
在场景设计器里,这个偏置可以通过给途经点加偏移量实现。注意方向:右侧车道对应负偏移(假设 y 轴指向左)。我一般写个小工具函数处理这件事,避免每次手动算偏移方向:
function wpOffset = offsetAlongRight(wp, d) % OFFSETALONGRIGHT 把途经点沿路径法向右侧平移 d 米 dx = gradient(wp(:,1)); dy = gradient(wp(:,2)); len = hypot(dx, dy); nx = dy ./ len; % 左法向 ny = -dx ./ len; wpOffset = wp - d * [nx, ny]; % 右偏 d 米 end用中心差分(gradient)算切向再做 90° 旋转得到法向,这是处理离散点列的标准手法。要注意首尾点的gradient是单边差分,精度略低,如果偏置量不大(1.75 米这种量级)影响可以忽略。
还有个更省事的路径:直接用lanespec定义好车道之后,让车辆"关联"到某条车道上,场景设计器会自动把它约束在车道内。但我不太推荐在弯道场景里完全依赖这个约束,原因是它会让你的场景"太理想"——车永远不越线,你就测不出压线、跨车道这些真实场景下的边界行为。人工给途经点虽然麻烦,但可控性高得多。
5. 加传感器、看鸟瞰图、导出仿真
5.1 传感器安装位姿的三个参数
弯道场景最典型的用途之一,是验证车道线检测在曲率变化下的表现。这就需要给车挂一个前视摄像头。在场景设计器的Sensors页里添加摄像头,本质上要填三个东西:安装位置、安装高度、朝向。
安装位置一般取车头前保险杠后方一点,比如[2.5, 0](相对车辆后轴中心的纵向、横向偏移),高度取1.2到1.4米,接近真实乘用车的前摄像头高度。朝向通常正前方,俯仰角略微向下 1 到 3 度,这样能在有限画幅里多看到一点近处的车道线——这对弯道特别重要,因为弯道中远端的车道线已经弯出视野了,靠的是近端线来拟合曲率。
导出的代码大致长这样(具体字段随版本略有差异,以界面导出为准):
camera = monoCamera( ... 'SensorLocation', [2.5 0], ... 'Height', 1.3, ... 'Yaw', 0, ... 'Pitch', -2, ... 'Roll', 0);我要强调的一点是:传感器的位姿必须在场景坐标系和车辆坐标系之间想清楚。场景设计器里的SensorLocation是相对车辆本体的,不是绝对坐标。我曾经把这个偏移按世界坐标填了一次,结果摄像头挂在车后面 2.5 米的位置,画面里全是天空,排查了很久才反应过来。
雷达的坑不太一样。雷达的探测距离和视场角决定了它在弯道里能不能"看见"前方车辆——R=50 的弯上,前方车辆如果在对向车道,很可能已经超出视场角了。这不是 bug,是真实的物理限制。如果你的算法依赖雷达做前车检测,弯道场景里就要接受"间歇性丢失目标"这个事实,并且验证算法在这段时间里的行为是否安全。
5.2 鸟瞰图和追随视角的快速自检
场景跑起来之后,别急着看算法输出,先肉眼确认几何对不对。两个视图特别有用:
% 鸟瞰全局,看道路形状和整条轨迹 figure; plot(scenario, 'Waypoints', 'on'); % 跟随自车视角,看车道关系 chasePlot(ego, 'Centerline', 'on');chasePlot画出来的是车后方跟随视角,带车道边界。这个视角最适合判断"车是不是真的走在车道中间"。我在调横向偏置的时候,基本上就是盯着这个图,看车身和左右边界的距离是否目测相等。
再进一步,可以用鸟瞰图对象做轨迹叠加检查:
bep = birdsEyePlot('XLimits', [-20 80], 'YLimits', [-20 100]); plotter = laneBoundaryPlotter(bep, 'Color', [0.5 0.5 0.5]); plotLaneBoundary(plotter, ego.LaneBoundaries);ego.LaneBoundaries这个属性会随车辆移动实时更新,把当前感知到的车道边界画出来。如果这条线和道路的实际边界在弯中明显分离,说明车道关联出了问题,多半是车辆位置离中心线太远,超出了关联阈值。
这里有个经验:弯道场景的自检,最好在三个位置各看一次——入弯前、弯中、出弯后。入弯前看的是减速是否及时,弯中看的是横向偏差最大值,出弯后看的是有没有超调。三个位置都没问题,这个场景才算搭对了。
5.3 导出的三种形态与选择依据
场景搭好后有三种导出方式,用途完全不一样:
| 导出形态 | 操作位置 | 适用场景 | 主要限制 |
|---|---|---|---|
| MATLAB Function | 界面 Export 菜单 | 固化场景、做回归测试、参数化重生成 | 只含道路和参与者,不含传感器检测逻辑 |
| Simulink 模型 | 界面 Export 菜单 | 闭环控制验证、接入自己的算法模块 | 需要 Scenario Reader 模块正确指向场景文件 |
| 直接在脚本中调用 | 无需导出 | 快速验证、批量生成多组参数 | 每次都要跑一遍构建脚本 |
第一种是我用得最多的。把界面上调好的场景导出成函数之后,我会把它和一组断言放在一起,做成一个最小回归测试:半径是不是 50、车道数是不是 2、自车轨迹的最大横向偏差是不是小于 0.2 米。这样以后换了工具箱版本或者改了参数,跑一遍就知道有没有被破坏。
第二种适合闭环。导出到 Simulink 之后,模型里会有一个 Scenario Reader 模块读取场景,输出车辆状态和传感器检测结果,你把自己的控制器接在后面就行。这里最常见的坑是场景文件的路径——模型里存的是绝对路径,换台机器就找不到文件。所以导出之后第一件事是把路径改成相对路径,或者用fileparts(mfilename('fullpath'))拼出来。
第三种适合参数扫描。比如你想看 R = 30、50、80、120 四组半径下算法的表现差异,写个for循环改R重新构建场景、重新跑,一次跑完四组,比在界面上手动改四次快得多。
6. 我在弯道场景里踩过的五个坑
6.1 路段拼接点重复导致的"零长度段"
这是最隐蔽的一个。你把入弯直线段的终点和圆弧段的起点直接拼起来,两个点的坐标完全一样,这在几何上没问题,但在场景内部会产生一个长度为零的路段。表现出来的症状是:plot(scenario)看着一切正常,但车辆跑起来到那个位置会突然抖一下,或者干脆报错说找不到对应车道。
原因在于曲线的参数化——零长度段会让切向无法定义,数值上除以零。解决办法很简单,拼接时去掉重复点:[segA; segB(2:end,:)]。我现在的习惯是在拼接之后加一句检查:
d = vecnorm(diff(roadCenters), 2, 2); assert(all(d > 1e-3), '中心线存在重复点或过短段,检查拼接逻辑');几行断言,能省掉半小时的困惑。类似的检查我还加了曲率连续性、总长度是否合理这些,都放在构建脚本的末尾,形成一个"场景健康检查"小节。
6.2 车道宽度数组的顺序是从左到右
lanespec的Width参数是个向量,长度必须等于总车道数,顺序是从最左侧车道到最右侧车道。如果你设的是lanespec([2 1], 'Width', [3.5 3.5 3.75]),那么最左边那条 3.5 米、中间 3.5 米、右边 3.75 米——右侧车道更宽这条规则,正好落在最后一个元素上。
这个顺序搞反了在对称场景(左右宽度相同)里完全看不出来,一旦做不对称场景(比如带公交专用道的路、或者一侧有硬路肩),车道边界就全错了,而且错误很隐蔽——道路看着还是那条路,只是车辆走的车道位置偏了。我现在的做法是永远用命名变量传参,别在函数调用里直接写字面量数组:
wLeft = [3.5 3.5]; % 左侧两条车道 wRight = [3.75 3.5]; % 右侧两条,靠外的更宽 lanespec([2 2], 'Width', [wLeft, fliplr(wRight)]);注意这里右侧用了fliplr,因为从最左到最右的顺序,右侧车道的排列是从内到外反过来。这种细节写清楚了,下次改参数才不容易错。
6.3 Yaw 用度不用弧度
前面提过一次,但值得单独拿出来说,因为它是高频错误。场景设计器里所有涉及朝向、角度的地方——vehicle的Yaw、monoCamera的Yaw/Pitch/Roll——单位都是度。而 MATLAB 里绝大多数三角函数默认吃弧度。这两套体系混在一起,出错的时候不会有任何报错,只是结果不对。
我的应对办法是:凡是算角度的地方,一律用带d后缀的函数(atan2d、sind、cosd),从源头就保持在角度制里,不做中间换算。需要转弧度的地方明确写deg2rad,让代码自己说明意图。这个习惯养成之后,因为角度单位出错的次数基本降到零。
6.4 车辆初始位置悬空
场景里只有道路没有网格的时候,车辆位置是自由的,放哪儿都行。但一旦道路定义好了,车辆位置如果偏离道路太远,smoothTrajectory可能会报错或者生成一段诡异的过渡轨迹。
更麻烦的情况是 z 坐标。道路中心线如果给了 z 值(做坡道场景),而车辆位置 z 填了 0,车就会"陷进"路面或者悬在空中。虽然平面上看不出来,但一旦接入传感器仿真,摄像头高度基准就错了,画面里的地平线位置会明显不对。我现在的做法是初始位置直接取roadCenters(1,:),连 z 一起拿过来,不单独填。
6.5 仿真步长与场景采样率不匹配
drivingScenario('SampleTime', 0.05)里设的是场景的采样周期,也就是 20 Hz。这个值需要和你后续接入的控制器采样率对齐,或者至少成整数倍关系。如果场景跑 20 Hz、控制器跑 50 Hz,中间就需要插值,而插值本身会引入相位滞后,在弯道这种动态场景里会让横向误差评估偏大。
我的经验值是:场景采样率取控制器采样率的整数倍,或者干脆相等。横向控制一般 20 到 50 Hz,场景给 0.02 到 0.05 秒都能接受。如果要做高频的传感器仿真(比如摄像头 30 帧),场景采样率至少要能给到 30 Hz 以上,否则图像序列的时间戳会很别扭。
还有StopTime,这个不是"随便给个够长就行"。给太短,车还没出弯仿真就结束了,看不出出弯超调;给太长,后半段全是直线,白跑。合理的算法是:总路程 / 平均速度 + 20% 余量。我一般在脚本里根据中心线总长自动算:
totalLen = sum(vecnorm(diff(roadCenters), 2, 2)); scenario.StopTime = totalLen / (0.7 * vTarget);用 0.7 倍目标速度作为平均速度的保守估计,能保证仿真覆盖整条路径还留有余量。
最后一个想说的小技巧:把常用的弯道参数存成一个结构体数组,比如curveLib(1) = struct('R',30,'theta',90,'v',8),写几个典型场景(城市右转、高速匝道、环岛入口),以后要测哪类场景直接索引,不用每次重新想参数。这个习惯是从做参数扫描的时候养成的,后来发现它对"和同事对齐测试场景"特别有用——大家用同一套库,结果才有可比性。搭场景这件事,看着是在做几何,实际上是在定义测试的边界条件,边界定得清楚,后面的结论才站得住。