Post

Lane Change Planner: 차선으로 동적 장애물 추월하기

Lane Change Planner: 차선으로 동적 장애물 추월하기

동적 장애물 회피는 추월을 성공시키는 데 필수적인 요소이지만, 자신과 상대의 상태, 트랙 정보를 함께 활용해 좁은 트랙에서 추월을 성사시키는 것은 상당히 어려운 과제입니다.

MPC처럼 자신과 상대의 timestep을 함께 고려해 step별 행동을 계획하거나, 제약조건을 걸어 솔버로 회피 궤적을 계산하는 방식은 솔루션을 예측하기 어렵고 매 순간 feasible한 해를 보장하기도 어렵습니다. 변수가 끊임없이 발생하는 RoboRacer 도메인에 그대로 쓰기에는 까다롭습니다.

UNICORN Racing 팀은 이러한 한계를 고려하여, 미리 만들어 둔 차선 정보를 활용하는 간단한 동적 회피 알고리즘으로 좁은 트랙에 대응하고 있습니다.

본 포스트에서는 최적 라인을 중심으로 좌·우에 두 개의 차선을 미리 만들어 두고, 상대 차량이 앞을 막으면 빈 쪽 차선으로 옮겨 붙었다가 다시 최적 라인으로 복귀하는 회피 경로를 실시간으로 생성하는 Lane Change Planner를 다룹니다.

작동 흐름

관련 코드: planner/lane_change_planner/lane_change_planner/change_avoidance_node.py

노드가 시작될 때 좌·우 차선을 한 번 만들어 두고, 이후 매 프레임 아래 흐름을 반복합니다. 각 단계의 파라미터는 주행 중 rqt로 실시간 조절할 수 있습니다.

  1. 차선 선택: 장애물과 트랙 정보로 좌·우 중 빈 쪽 차선을 고릅니다.
  2. d 프로파일 생성: 해당 차선으로 이동하다가 최적 라인으로 빠지는 횡변위 $d$ 프로파일을 계획합니다.
  3. 스무딩·검증: $(x, y)$ 곡선으로 변환해 부드럽게 스무딩하며, 경로가 트랙 안에 위치하면 발행합니다.

차선과 회피 경로

흰색 = 센터라인, 초록·빨강 = 미리 만들어 둔 좌·우 차선, 노란 점선 = 학습된 앞 차량의 궤적, 검은 점선 = 생성된 회피 경로.

검은 점선(회피 경로)이 최적 라인에서 위 차선으로 부드럽게 붙어 장애물 옆을 지난 뒤 다시 최적 라인으로 합류합니다.

0. 좌·우 차선 생성 (generate_lanes)

회피의 목표가 될 두 차선을 노드 시작 시 한 번 만들어 둡니다. /centerline_waypoints로 받은 트랙 중앙선의 각 점에서, 그 점의 heading에 수직인 단위 법선 방향으로 lane_offset(기본 0.35m)만큼 좌·우로 밀어 두 차선을 만듭니다.

  • 좌측 차선: 중앙선을 좌측(+d)으로 민 차선
  • 우측 차선: 중앙선을 우측(-d)으로 민 차선
\[p_{left}(s) = c(s) + w\,n(s), \qquad p_{right}(s) = c(s) - w\,n(s)\]

여기서 $c(s)$ 는 centerline 점, $n(s)$ 는 단위 법선, $w$ 는 lane_offset입니다. 두 차선은 0.1m 간격으로 재샘플링한 뒤 Frenet 좌표 $(s, d)$ 로 변환해 저장해 둡니다.

lane_offset은 rqt에서 실시간으로 조절할 수 있고, 값이 바뀌면 두 차선을 즉시 다시 생성합니다.

  • 키우면: 차선이 벽 쪽으로 붙어 상대와 더 넓게 벌어져 지나갑니다.
  • 줄이면: 최적 라인에 가깝게 지나갑니다.

법선 부호는 트랙의 진행 방향(CW/CCW)에 따라 두 차선이 실제 좌·우와 뒤바뀔 수 있습니다. 이후 코드는 한쪽 차선을 항상 좌측(+d)으로 취급하므로, 그 차선의 평균 d가 음수면(실제로는 우측이면) 두 차선을 맞바꿔 좌·우 규약을 맞춥니다.

생성된 두 차선은 /planner/avoidance/lane_markers로 5Hz마다 발행됩니다. RViz에서 흰 centerline을 기준으로 초록(좌)·빨강(우) 두 선이 나란히 보입니다.

1. 장애물 선별과 추월 게이트

이 planner는 유효한 예측 정보가 들어오면 위치와 무관하게 회피 경로를 생성해 발행합니다. 다만 그 경로를 실제로 추월에 쓸지는 state machine이 판단합니다. 맵의 ot_sectors.yaml에서 ot_flag가 켜진 구간에 차량이 들어와 있을 때만 OVERTAKE 상태로 전환해 이 경로를 채택하며, 켜지지 않은 구간에서는 회피 경로가 발행되어 있어도 추월하지 않습니다.

각 구간의 ot_flag로 그 구간에서 추월을 허용할지를 지정합니다. 직선처럼 안전한 구간만 켜 두면, 위험한 코너에서 무리하게 추월하는 것을 막을 수 있습니다. 어느 구간도 켜져 있지 않으면 추월은 전 구간에서 억제됩니다.

RQT overtaking sector 설정 RQT의 overtaking sector 설정 (각 구간의 start·end와 ot_flag)

회피 대상 장애물 선별

추월이 허용된 구간에 들어오면, 인식된 동적 장애물 중 아래 두 조건을 모두 만족하는 것만 회피 대상으로 남깁니다.

  • 전방 lookahead(15m) 이내
  • 최적 라인에서 횡방향으로 obs_traj_tresh(2.0m) 이내

대상이 있어도 곧바로 궤적을 만들지는 않고, 아래 조건을 모두 만족해야 회피 궤적을 생성합니다.

조건통과 기준
회피 대상 존재 (considered_obs)위 필터를 통과한 장애물이 하나 이상
예측 신선도 (prediction_is_fresh)최근 pred_timeout(0.5s) 이내에 GP 궤적 예측이 도착
강제 추종 해제 (force_trailing)GP 예측을 신뢰할 수 없을 때 켜지는 강제 추종 전환 명령이 꺼져 있음

하나라도 만족하지 못하면 회피 경로 생성 단계까지 진행하지 않아, 발행되는 회피 경로가 없습니다. 예측이 불확실할 때 무리한 추월 경로가 나가는 것을 막는 장치입니다.

GP 예측기는 상대의 궤적 데이터가 1바퀴 분량(누적 이동 거리 ≥ 트랙 길이)만큼 쌓이기 전까지는 학습된 예측을 내놓지 못합니다. 이 구간에서는 궤적 토픽이 발행되지 않거나 force_trailingTrue로 유지되고, lane change 노드가 이 토픽을 게이트로 구독하므로 회피 없이 트레일링만 합니다. 데이터가 중간에 끊기면 누적이 리셋되므로, 유효한 1바퀴치 궤적이 모인 뒤부터 추월이 활성화됩니다.

위 영상에서 상대의 첫 바퀴 동안에는 뒤를 따르다가, 측정이 끝난 뒤 옆 차선으로 추월합니다. 트랙에 공간이 충분함에도 불구하고 예측 정보가 부족하여 추월 궤적이 나오지 않는 모습을 볼 수 있습니다.

2. 피할 차선 결정 (more_space)

좌·우 중 빈 공간이 더 넓은 쪽으로 피합니다. more_space는 각 장애물에 대해 그 장애물의 가장자리부터 좌·우 벽까지 남는 여유를 재고, 차폭과 안전 마진을 고려한 최소 공간(min_space)만큼 비어 있는 쪽을 후보로 삼습니다.

1
2
3
left_gap  = (좌측 벽 여유) - 장애물 좌측 가장자리
right_gap = (우측 벽 여유) + 장애물 우측 가장자리
min_space = spline_bound_mindist + width_car/2 + safety_margin
  • 한쪽만 넉넉하면 그쪽으로 정합니다.
  • 양쪽 다 넉넉하면 더 넓은 쪽을 고릅니다.
  • 양쪽 다 좁으면 지나갈 공간이 없다고 보고 회피하지 않습니다(None 반환).

여러 장애물이 겹쳐 있으면 각 장애물마다 좌·우 중 통과 가능한 쪽을 집계합니다. 최종 방향(preferred_side)은 세 조건을 모두 만족하는 쪽으로 정합니다.

  • 그 방향을 선택한 장애물 수가 더 많음
  • 그 방향의 평균 여유가 반대쪽보다 넓음
  • 그 평균 여유가 차폭보다 큼

방향 떨림 억제 (hysteresis)

프레임마다 여유 공간이 근소하게 변동하면 선택 방향이 좌우로 바뀌어 경로가 불안정해질 수 있습니다. 이를 막기 위해, 새 방향이 연속 side_switch_frames (10프레임) 동안 유지될 때만 실제로 방향을 바꿉니다(_apply_side_hysteresis). 20Hz 기준 약 0.5초로, 순간적인 gap 변동으로 인한 방향 전환을 걸러냅니다.

3. d-프로파일 생성

방향이 정해지면 그 쪽 차선을 목표로 삼아 회피 경로의 횡변위 $d$ 를 그립니다. 정적 회피가 apex 한 점을 찍었던 것과 달리, 여기서는 미리 만들어 둔 차선의 $d$ 값을 장애물 구간 내내 그대로 따라갑니다.

차량 위치에 해당하는 $s$ 지점에서 시작해 장애물 끝을 지나 복귀 지점까지 $s$ 축을 펼치고, 각 $s$ 에서 목표 차선의 $d$ 값을 구합니다. 여기서 장애물 구간은 예측된 상대 궤적의 $s$ 범위(시작~끝)이며, 이 구간을 중심으로 cosine ease 가중치 $w$ 를 걸어 최종 $d$ 를 만듭니다.

  • 진입 램프(back_to_raceline_before): 라인에서 차선으로 ($w$: 0 → 1)
  • 장애물 구간: 차선에 붙어 통과 ($w$ = 1)
  • 복귀 램프(back_to_raceline_after): 예측 궤적의 끝 $s$ 부터 차선에서 라인으로 ($w$: 1 → 0)

즉 복귀 램프는 예측된 상대 궤적이 끝나는 지점부터 시작합니다. 진입 램프와 복귀 램프의 폭을 따로 두어, 상대에게 다가가는 구간과 추월 후 최적 라인으로 돌아오는 구간을 독립적으로 조절할 수 있습니다.

진입 램프의 시작점은 장애물 시작 − before로 고정되지만, 경로는 항상 차량 위치에 해당하는 $s$ 지점에서 그려집니다. 따라서 차량의 현재 $s$ 가 램프 구간의 어디쯤($s$ 비율)에 있느냐가 곧 출발 시점의 $d$ 값을 정합니다. 램프 시작보다 뒤에 있으면 $d=0$(라인)에서 완만히 출발하고, 이미 램프 구간 안에 들어와 있으면 그 $s$ 비율만큼 벌어진 $d$ 에서 시작해 남은 램프 안에서 급하게 붙습니다.

이때 차량이 램프의 어디쯤에서 출발하는지는 back_to_raceline_before와 상대와의 trailing 거리가 함께 정합니다. 두 값의 조합에 따라 조향의 부드러움이 달라집니다.

차량이 진입 램프 중간에 있는 경우 (trailing 거리가 짧고 before가 길 때) 램프 시작점이 차량 뒤에 놓여, 차량 발밑이 아니라 옆에서 나오는 경로를 따라가게 되므로 순간적으로 급격한 조향이 발생할 수 있습니다.

진입 램프 중간에서 출발하는 경우 차량이 진입 램프 중간에 있는 경우: 램프 시작이 차량 뒤에 있어 경로가 차량 옆에서 시작되는 모습

before가 짧은 경우에도 궤적 자체가 급격한 형상을 그려, 마찬가지로 급격한 조향이 발생할 수 있습니다.

before가 짧은 경우 before가 짧은 경우: 회피 경로가 장애물 가까이에서 급하게 좌측 차선으로 꺾여 붙는 모습

반대로 trailing 거리가 길면before로 램프 전체를 써서 부드러운 조향으로 붙을 수 있지만, 상대차와의 거리가 멀어 추월 성사에는 불리하게 작용할 수 있습니다.

이처럼 부드러운 조향과 추월 성공률은 서로 상충하므로, 트랙 폭과 상대차와의 속도 차 등을 고려한 적절한 튜닝이 필요합니다. cosine ease 덕분에 어느 경우든 차선에 붙고 떨어지는 이음매에서 $d$ 자체는 매끄럽게 이어집니다.

4. 발행과 디버깅

트랙 안에 있는 것으로 검증된 회피 경로가 발행되어 state machine과 추종 제어로 넘어갑니다. 발행 토픽은 다음과 같으며, RViz에서 구독하면 차선과 회피 경로, 검증 상태를 눈으로 확인할 수 있습니다.

  • /planner/avoidance/otwpnts: 발행되는 회피 경로
  • /planner/avoidance/merger: 복귀 구간. “장애물 끝 s”와 “회피 경로 끝 s”를 함께 실어, 회피 후 어디서부터 최적 라인으로 합류할지를 하위 모듈에 알립니다.
  • /planner/avoidance/lane_markers: 미리 만들어 둔 좌·우 차선
  • /spline_sample_points: 트랙 안전 검증에 쓰인 샘플 점 (밖으로 벗어난 점이 있으면 경로가 폐기됨)

UNICORN Racing Stack에서 직접 실행하기

Lane Change Planner는 race.launch.xml에 포함되어 있고 기본 추월 planner로 설정되어 있어, 전체 주행 스택을 띄우면 별도 지정 없이 함께 실행됩니다.

1
2
unicorn          # conda 환경 진입
ros2 launch stack_master race.launch.xml map:=f sim:=true
  • map: 맵 이름 (stack_master/maps/ 하위 폴더)
  • sim: 시뮬레이션이면 true, 실차면 false

RQT(Dynamic Reconfigure)로 튜닝하기

이 planner의 회피 파라미터는 모두 dynamic reconfigure로 선언되어 있어, 노드를 재시작하지 않고 rqt에서 실시간으로 조절하며 회피 거동이 어떻게 바뀌는지 확인할 수 있습니다.

1
2
3
# 터미널 1: 주행 스택 (lane_change가 기본 추월 planner)
unicorn
ros2 launch stack_master race.launch.xml map:=f sim:=true
1
2
3
# 터미널 2: rqt (반드시 같은 unicorn 환경)
unicorn
rqt

각 파라미터는 앞서 설명한 단계와 직접 연결됩니다. 값을 바꾸면 노드 재시작 없이 다음 프레임의 회피 경로에 바로 반영되므로, RViz의 차선 마커(lane_markers)와 회피 경로(otwpnts)를 함께 보며 거동 변화를 확인할 수 있습니다.

파라미터기본값기능
lane_offset0.35centerline에서 좌·우 차선까지의 거리 [m]. 값을 바꾸면 두 차선이 즉시 재생성되며, 키우면 차선이 벽 쪽으로 붙어 상대와 더 넓게 벌어지고 줄이면 최적 라인에 가깝게 지나갑니다 (0단계).
back_to_raceline_before3.0장애물 앞에서 차선으로 진입하는 램프 폭 [m]. 크게 두면 더 일찍 완만하게 차선으로 붙습니다 (3단계).
back_to_raceline_after3.0장애물 뒤에서 최적 라인으로 복귀하는 램프 폭 [m]. 진입과 독립적으로 복귀 완만함을 조절합니다 (3단계).
obs_traj_tresh2.0최적 라인에서 이 횡거리 이내의 장애물만 회피 대상으로 고려하는 임계값 [m] (1단계).
spline_bound_mindist0.3회피 방향을 정할 때 요구하는 최소 공간과 경로가 벽에 가까워지는 하한을 결정하는 여유값 [m] (2단계).
safety_margin0.1차량·장애물 주변에 두는 안전 마진 [m] (2단계).
evasion_dist0.3장애물 옆으로 확보하는 여유 거리 [m].
max_evasion_start_offset0.8회피 시작점에서 요구되는 횡점프가 이 값보다 크면 경로를 폐기하는 상한 [m] (4단계).
pred_timeout0.5GP 예측이 최근 이 시간 내에 도착해야 추월을 시작하는 신선도 기준 [s] (1단계).

주행 예시

폭이 좁은 트랙(map_test)에서도 상대를 옆 차선으로 안정적으로 추월하는 모습입니다.

마무리

이 글에서는 센터라인 좌·우에 미리 만들어 둔 두 차선을 회피 목표로 삼아, 장애물 상황에서 빈 차선으로 옮겨 붙었다가 다시 최적 라인으로 복귀하는 Lane Change Planner의 동작을 단계별로 살펴봤습니다. 차선 생성 → 추월 게이트(ot_sectors·GP 예측 신선도) → 방향 결정(hysteresis) → cosine ease d-프로파일 → 검증·발행 순으로 이어지며, 핵심 튜닝 포인트는 lane_offset과 진입·복귀 램프 폭(back_to_raceline_before/after)입니다. 정적 장애물 회피는 Static Obstacle Avoidance를 참고하세요.

This post is licensed under CC BY 4.0 by the author.