Devin.KR

tf2 활용 - 시간 여행과 센서 프레임

개발자KR 조회 3

이 장에서 배우는 것

앞 장에서 두리의 위치를 프레임 트리로 표현했다. 프레임 사이에 연결이 있으면 좌표를 바꿀 수 있지만, 움직이는 로봇에서는 연결만으로 충분하지 않다. 같은 센서 좌표라도 관측한 시각에 따라 지도 위의 위치가 달라진다. 두리가 이동하는 동안 도착한 센서 데이터를 처리하려면 프레임 이름과 시각을 함께 다뤄야 한다.

이 장에서는 특정 시각의 변환을 조회하고, 기록 범위를 벗어난 요청을 구분하며, 센서의 장착 위치를 변환에 포함한다. 시간 여행이라는 표현은 저장한 변환 기록으로 서로 다른 시각의 좌표계를 연결한다는 뜻이다. 미래의 로봇 위치를 예측한다는 뜻은 아니다.

  • 센서 메시지의 관측 시각과 최신 변환 조회를 구분한다.
  • 과거와 미래 방향의 외삽 오류를 기록 범위와 연결해 설명한다.
  • 센서 장착 오프셋을 정적 변환으로 표현한다.
  • 서로 다른 두 시각의 프레임을 고정 프레임을 거쳐 연결한다.
  • 순수 Python 계산과 ROS 2 Jazzy의 tf2 조회 결과를 비교한다.

문제 상황

두리가 보도에서 앞으로 이동하면서 거리 센서로 장애물을 관측한다. 센서는 차체 기준점보다 0.2m 앞에 달려 있다. 관측 당시 센서에서 장애물까지의 거리는 전방 2m였다. 메시지 처리에 시간이 걸렸고, 처리 시점에는 두리가 관측 당시보다 2m 더 앞으로 이동했다.

여기서 최신 차체 위치에 센서 관측값을 더하면 장애물도 두리를 따라 앞으로 이동한 것처럼 보인다. 센서가 보고한 2m는 관측 당시 센서 원점에서 측정한 값이다. 처리 시점의 센서 원점을 기준으로 한 거리가 아니다. 센서 메시지의 시각을 버리는 순간, 공간 계산은 맞더라도 사용한 좌표계가 달라진다.

장착 위치를 생략하면 또 다른 오차가 생긴다. 센서가 차체 중심에 있다고 가정하면 장애물 위치가 실제 계산값보다 0.2m 뒤로 밀린다. 시간 오차와 장착 오차는 별개의 원인이므로 각각 확인해야 한다. 특히 회전 중에는 장착 위치가 차체 중심에서 멀수록 잘못된 시각을 사용했을 때의 위치 차이도 커질 수 있다.

예제에서는 계산을 눈으로 검산할 수 있도록 두리가 회전하지 않고 전진한다고 가정한다. 10초의 차체 위치는 odom 기준 x=1m, 12초에는 x=3m다. 센서는 차체보다 x 방향으로 0.2m 앞에 있고, 10초에 센서 기준 x=2m인 점을 관측한다. 이 점의 관측 당시 odom 좌표는 3.2m다.

변환은 프레임과 시각으로 고른다

tf2의 버퍼(buffer)는 시간에 따른 변환 기록을 보관한다. 변환 조회에는 목표 프레임, 원본 프레임, 조회 시각이 필요하다. lookup_transform("odom", "sensor", stamp)는 stamp 시각의 sensor 좌표를 같은 시각의 odom 좌표로 바꾸는 변환을 요청한다. 반환값은 원본 좌표에 적용할 회전과 평행이동이다.

센서 메시지의 header.frame_id는 좌표를 표현한 프레임이고, header.stamp는 그 좌표에 대응하는 시각이다. 드라이버가 관측 시각을 기록한다면 두 필드를 한 쌍으로 유지해야 한다. 수신 시각이나 콜백 실행 시각으로 stamp를 덮어쓰면 지연을 보정하는 데 필요한 정보가 사라진다.

tf2에서 시각 값 0은 최신 변환을 요청하는 특별한 의미를 가진다. 이는 기록의 시작 시각을 요청하는 방법이 아니다. 경로에 여러 동적 연결이 있으면 조회 가능한 공통 최신 시각의 영향을 받으므로, 각 연결에서 가장 최근에 받은 값을 서로 다른 시각 그대로 이어 붙인다고 이해해서도 안 된다.

조회 시각에 따라 좌표 계산의 의미가 달라진다
요청선택하는 시각적합한 상황
메시지의 stamp관측 데이터가 가리키는 시각센서 관측을 다른 프레임에 배치
시각 값 0경로에서 조회 가능한 최신 시각최신 프레임 관계 확인
현재 시각조회자가 읽은 현재 시각그 시각의 기록이 도착했을 때 조회
원본·목표 시각을 따로 지정서로 다른 두 시각과거 관측을 이후 차체 기준으로 표현

기록 사이의 시각을 요청하면 tf2는 보간(interpolation)을 사용할 수 있다. 예를 들어 10초와 12초의 차체 위치 사이에서 11초의 위치를 구할 수 있다. 평행이동은 선형으로, 회전은 회전 표현에 맞게 보간한다. 다만 기록이 드문 구간에서 로봇이 급격하게 움직였다면 보간 결과와 실제 움직임 사이에 차이가 생길 수 있다.

이 장의 순수 Python 예제는 평면 위치와 방향각만 다룬다. 방향각은 짧은 회전 방향으로 보간한다. 실제 tf2가 사용하는 3차원 회전을 그대로 구현한 코드는 아니지만, 시간 범위와 좌표 합성의 관계를 확인하는 데 사용할 수 있다.

10초와 12초 사이에서는 보간할 수 있지만 기록 밖의 9초와 13초는 외삽 오류가 된다

그림의 기록 범위는 예제의 두 동적 기록을 기준으로 한다. 실제 조회 경로에서는 모든 동적 연결이 요청 시각을 지원해야 한다. 한 연결의 기록이 충분해도 다른 연결의 최신 기록이 늦으면 전체 조회가 실패할 수 있다.

외삽 오류는 시간 범위를 알려 준다

외삽(extrapolation)은 보관한 기록의 바깥 시각으로 값을 확장하는 계산이다. tf2는 동적 변환을 조회할 때 이러한 예측을 수행하지 않는다. 가장 오래된 기록보다 과거이거나 가장 최신 기록보다 미래인 시각을 요청하면 외삽 오류가 발생할 수 있다.

과거 방향 오류는 오래 지연된 메시지, 짧은 보관 기간, 조회 노드의 늦은 시작 등으로 생긴다. 버퍼의 보관 기간을 늘리면 앞으로 수신하는 기록을 더 오래 유지할 수 있지만, 이미 사라졌거나 애초에 수신하지 않은 기록이 되살아나지는 않는다. 시작 직후에는 변환 기록이 충분히 쌓였는지도 확인해야 한다.

미래 방향 오류는 반드시 잘못된 미래 시각을 지정했다는 뜻은 아니다. 센서 메시지가 먼저 도착하고 같은 시각의 변환이 조금 뒤에 도착할 수 있다. 현재 시각을 읽어 바로 조회할 때도 변환 발행과 전달에 시간이 걸리므로 버퍼의 최신 시각보다 앞선 요청이 되기 쉽다.

따라서 대기 시간(timeout)은 허용할 전달 지연을 정하는 값이다. 기록 범위 밖의 좌표를 예측하도록 허용하는 값이 아니다. 대기 중에도 변환을 수신하는 작업이 진행되어야 하므로, 같은 실행 흐름을 오래 붙잡는 방식만으로 문제를 해결하려 해서는 안 된다. 실무에서는 관측을 잠시 보관했다가 다시 조회하거나 비동기 대기를 사용한다.

컴퓨터 시간과 시뮬레이션 시간을 섞은 경우에는 잠깐 기다려도 해결되지 않는다. 메시지 시각과 변환 발행 시각이 같은 시간 기준을 사용하는지 먼저 확인한다. 시뮬레이션 시간이 뒤로 이동했을 때는 이전 구간의 관측을 계속 처리해도 되는지 검토하고, 새 시간 구간의 변환 기록을 확보해야 한다.

조회 실패가 모두 외삽 오류인 것은 아니다. 아직 알려지지 않은 프레임을 요청하거나 프레임 사이의 연결이 없는 경우도 있다. 운영 코드에서는 프레임 이름, 요청 시각, 오류 내용을 함께 남겨 공간 연결 문제와 시간 범위 문제를 구별한다. 완성 코드는 알려진 트리에서 시간 범위만 벗어나도록 만들어 외삽 오류를 따로 확인한다.

센서 장착 위치와 두 시각을 함께 연결한다

센서 장착 오프셋(offset)은 차체 기준점에서 센서 원점까지의 위치와 센서 축의 방향이다. 장착 위치가 고정되어 있다면 base_link → sensor를 정적 변환으로 표현한다. 차체가 움직여도 차체에 대한 센서의 상대 위치는 변하지 않는다. 반면 odom → base_link는 시간에 따라 달라지는 동적 변환이다.

이 예제의 센서는 차체보다 전방 0.2m에 있으며 축 방향은 차체와 같다. sensor 기준 x=2m인 점은 base_link 기준 x=2.2m다. 10초의 차체 위치가 odom 기준 x=1m이므로 최종 좌표는 3.2m다. 평행이동만 있을 때는 덧셈으로 보이지만, 센서가 회전해 달려 있다면 센서 점을 회전한 다음 장착 위치를 더해야 한다.

센서 관측점은 장착 변환을 거쳐 차체로 옮기고 관측 시각의 차체 변환을 거쳐 odom으로 옮긴다

점의 변환을 p_odom = T_odom_base(t) · T_base_sensor · p_sensor로 적을 수 있다. 오른쪽 변환부터 적용한다. 변환 이름에서 앞쪽 프레임은 결과를 표현하는 기준이고 뒤쪽 프레임은 입력 좌표의 기준이다. 이 순서를 바꾸면 일반적으로 같은 결과가 나오지 않는다.

이번에는 “10초에 관측한 점을 12초의 차체에서 보면 어디인가”를 묻는다. 먼저 점을 10초의 센서에서 odom으로 옮긴다. 다음으로 12초의 차체 위치를 사용해 odom에서 base_link로 옮긴다. 계산식은 p_base(12) = inverse(T_odom_base(12)) · T_odom_base(10) · T_base_sensor · p_sensor(10)다. 숫자를 대입하면 3.2−3.0=0.2m가 된다.

이 조회에는 원본 시각과 목표 시각이 따로 필요하다. tf2의 lookup_transform_full은 두 시각을 지정하고, 그 사이를 연결할 고정 프레임(fixed frame)을 받는다. 예제에서는 odom을 사용한다. “고정”은 연결 전체가 정적 변환이라는 뜻이 아니다. 서로 다른 시각을 연결하는 동안 공통 기준으로 삼는 프레임이라는 뜻이다.

이 계산은 과거에 관측한 공간상의 점을 이후의 차체 좌표로 다시 표현한다. 장애물이 스스로 움직였다면 그 물체의 12초 위치를 예측한 결과가 아니다. 또한 odom의 누적 오차가 크면 계산 결과에도 영향을 준다. 시간 변환의 의미와 물체 움직임에 관한 가정은 구분해야 한다.

완성 코드

두 파일은 서로 독립적으로 실행한다. 첫 파일은 Python 표준 라이브러리만 사용한다. 두 번째 파일은 ROS 2 Jazzy의 Python 모듈을 불러올 수 있는 환경에서 실행한다. 운영체제와 관계없이 첫 파일은 python3만 있으면 실행할 수 있다. 두 번째 파일은 사용하는 ROS 설치 환경에 맞게 환경 설정을 불러와야 한다.

ROS 예제는 통신 도착 순서의 영향을 없애기 위해 변환을 버퍼에 직접 넣는다. set_transform과 set_transform_static은 이 프로세스의 버퍼에 기록을 넣는 호출이며, 다른 노드로 변환을 발행하지 않는다. 실제 로봇에서는 같은 프레임 관계를 발행하는 노드와 이를 수신하는 TransformListener를 사용한다.

time_frames.py

from dataclasses import dataclass
from math import atan2, cos, sin


class ExtrapolationError(ValueError):
    pass


@dataclass(frozen=True)
class Pose2:
    x: float
    y: float
    yaw: float

    def apply(self, point):
        px, py = point
        c, s = cos(self.yaw), sin(self.yaw)
        return (
            self.x + c * px - s * py,
            self.y + s * px + c * py,
        )

    def compose(self, other):
        x, y = self.apply((other.x, other.y))
        return Pose2(x, y, self.yaw + other.yaw)

    def inverse(self):
        c, s = cos(self.yaw), sin(self.yaw)
        return Pose2(
            -c * self.x - s * self.y,
            s * self.x - c * self.y,
            -self.yaw,
        )


class History:
    def __init__(self, samples):
        self.samples = sorted(samples, key=lambda item: item[0])
        if not self.samples:
            raise ValueError("empty history")
        for left, right in zip(self.samples, self.samples[1:]):
            if left[0] >= right[0]:
                raise ValueError("duplicate time")

    def at(self, stamp):
        first_t, first_pose = self.samples[0]
        last_t, last_pose = self.samples[-1]
        if stamp is None:
            return last_pose
        if stamp < first_t:
            raise ExtrapolationError("past")
        if stamp > last_t:
            raise ExtrapolationError("future")
        if stamp == first_t:
            return first_pose
        if stamp == last_t:
            return last_pose

        for (ta, a), (tb, b) in zip(
            self.samples, self.samples[1:]
        ):
            if ta <= stamp <= tb:
                ratio = (stamp - ta) / (tb - ta)
                angle = atan2(
                    sin(b.yaw - a.yaw),
                    cos(b.yaw - a.yaw),
                )
                return Pose2(
                    a.x + ratio * (b.x - a.x),
                    a.y + ratio * (b.y - a.y),
                    a.yaw + ratio * angle,
                )
        raise ValueError("invalid time")


def main():
    history = History([
        (10.0, Pose2(1.0, 0.0, 0.0)),
        (12.0, Pose2(3.0, 0.0, 0.0)),
    ])
    mount = Pose2(0.2, 0.0, 0.0)
    observation = (2.0, 0.0)

    observed_tf = history.at(10.0).compose(mount)
    observed = observed_tf.apply(observation)
    latest = history.at(None).compose(mount).apply(observation)
    later_tf = history.at(12.0).inverse().compose(observed_tf)
    later = later_tf.apply(observation)

    assert abs(observed[0] - 3.2) < 1e-9
    assert abs(history.at(11.0).x - 2.0) < 1e-9
    assert abs(later[0] - 0.2) < 1e-9

    print(f"at 10 s: x={observed[0]:.3f}")
    print(f"latest: x={latest[0]:.3f}")
    print(f"base_link at 12 s: x={later[0]:.3f}")
    try:
        history.at(13.0)
    except ExtrapolationError:
        print("at 13 s: extrapolation")


if __name__ == "__main__":
    main()

tf2_time_query.py

import rclpy
from geometry_msgs.msg import PointStamped, TransformStamped
from rclpy.clock import ClockType
from rclpy.duration import Duration
from rclpy.time import Time
from tf2_geometry_msgs import do_transform_point
from tf2_ros import Buffer, ExtrapolationException


def ros_time(seconds):
    return Time(seconds=seconds, clock_type=ClockType.ROS_TIME)


def make_transform(parent, child, seconds, x):
    transform = TransformStamped()
    transform.header.frame_id = parent
    transform.child_frame_id = child
    transform.header.stamp = ros_time(seconds).to_msg()
    transform.transform.translation.x = float(x)
    transform.transform.rotation.w = 1.0
    return transform


def run():
    buffer = Buffer(cache_time=Duration(seconds=5.0))
    buffer.set_transform_static(
        make_transform("base_link", "sensor", 10, 0.2),
        "chapter_fixture",
    )
    for seconds, x in ((10, 1.0), (12, 3.0)):
        buffer.set_transform(
            make_transform("odom", "base_link", seconds, x),
            "chapter_fixture",
        )

    point = PointStamped()
    point.header.frame_id = "sensor"
    point.header.stamp = ros_time(10).to_msg()
    point.point.x = 2.0
    source_time = Time.from_msg(point.header.stamp)

    observed_tf = buffer.lookup_transform(
        "odom", point.header.frame_id, source_time
    )
    latest_tf = buffer.lookup_transform(
        "odom", point.header.frame_id, ros_time(0)
    )
    later_tf = buffer.lookup_transform_full(
        target_frame="base_link",
        target_time=ros_time(12),
        source_frame=point.header.frame_id,
        source_time=source_time,
        fixed_frame="odom",
    )

    observed = do_transform_point(point, observed_tf)
    latest = do_transform_point(point, latest_tf)
    later = do_transform_point(point, later_tf)
    print(f"at 10 s: x={observed.point.x:.3f}")
    print(f"latest: x={latest.point.x:.3f}")
    print(f"base_link at 12 s: x={later.point.x:.3f}")
    try:
        buffer.lookup_transform("odom", "sensor", ros_time(13))
    except ExtrapolationException:
        print("at 13 s: extrapolation")


def main():
    rclpy.init()
    try:
        run()
    finally:
        rclpy.shutdown()


if __name__ == "__main__":
    main()

줄별 해설

평면 좌표 계산과 시간 기록

Pose2는 다른 프레임의 원점 위치와 방향을 표현한다. apply의 첫 두 줄은 입력 좌표와 회전 계산값을 준비한다. 반환식은 점을 회전한 다음 평행이동한다. 장착 위치까지 함께 회전시키는 실수를 피하려면 이 순서를 식으로 확인하는 습관이 도움이 된다.

compose는 두 변환을 합성한다. self.apply로 안쪽 변환의 원점을 바깥쪽 프레임에 옮기고, 평면 방향각을 더한다. 따라서 history.at(10.0).compose(mount)는 센서에서 차체로, 차체에서 odom으로 옮기는 변환이다. inverse는 이동량의 부호만 바꾸지 않고 역회전도 적용한다. 회전이 포함되면 단순한 부호 반전만으로 역변환을 만들 수 없다.

History는 기록을 시각 순서로 정렬하고 빈 목록과 중복 시각을 거부한다. at는 범위 검사 후 정확히 일치하는 끝점이나 보간 결과를 반환한다. 여기서는 최신 조회를 None으로 표현했다. tf2의 시각 값 0과 역할은 같지만, 순수 Python 계산에서는 실제 숫자 0과 특별한 요청을 구분하기 위해 다른 표기를 쓴다.

ratio는 두 기록 사이에서 요청 시각이 차지하는 비율이다. atan2로 구한 방향 차이는 각도 경계를 넘을 때 긴 방향으로 회전하는 일을 피한다. 두 기록의 방향 차이가 정확히 반 바퀴라면 짧은 방향만으로 회전 경로를 결정할 수 없다는 한계가 있다.

observed는 관측 당시의 올바른 odom 좌표다. latest는 같은 숫자 좌표를 최신 센서 프레임의 점처럼 해석한 비교값이다. later_tf는 관측 당시 점을 odom에 올린 뒤 이후 차체의 역변환을 적용한다. 세 개의 assert는 관측 좌표, 보간 위치, 이후 차체 기준 좌표를 확인한다.

tf2 버퍼의 같은 계산

ros_time은 예제에 사용하는 모든 시각을 ROS 시간으로 만든다. 예제의 10초와 12초는 현재 컴퓨터 시각에서 가져온 값이 아니라 직접 넣은 기록의 시각이다. 시뮬레이션 시계를 별도로 발행하지 않아도 버퍼에 넣은 기록을 그 시각으로 조회할 수 있다.

make_transform의 부모 프레임은 header.frame_id, 자식 프레임은 child_frame_id에 들어간다. 저장된 변환은 자식 좌표를 부모 좌표로 옮긴다. 쿼터니언(quaternion)의 w를 1로 설정한 것은 회전이 없다는 뜻이다. 모든 성분을 기본값 0으로 두면 유효한 회전을 표현하지 못한다.

Buffer의 보관 기간은 5초다. 마지막 동적 기록이 12초이고 첫 기록이 10초이므로 두 기록을 함께 사용할 수 있다. 이 설정은 동적 기록의 시간 범위를 관리하는 값이며, 현재 벽시계와 비교해 5초 뒤에 기록을 지우는 단순한 타이머라고 이해하면 안 된다. 정적 장착 변환에는 동적 기록과 같은 방식의 시간 범위 제한이 적용되지 않는다.

source_time은 관측 메시지의 stamp에서 얻는다. 첫 조회는 관측 시각, 두 번째 조회는 최신 시각, 세 번째 조회는 두 시각을 사용한다. lookup_transform_full에 키워드 인자를 쓴 이유는 목표 시각과 원본 시각을 읽으면서 확인하기 위해서다.

do_transform_point는 전달받은 변환으로 점을 계산한다. 입력 점의 stamp를 보고 변환 시각을 다시 조회하거나, 전달한 변환이 관측 시각과 맞는지 검사하는 역할은 하지 않는다. 따라서 최신 변환을 일부러 적용한 비교 계산도 실행된다. 시각을 올바르게 선택할 책임은 조회를 구성하는 코드에 있다.

마지막 조회는 최신 기록보다 1초 뒤인 13초를 요청한다. 코드에서는 이 요청의 외삽 오류만 잡아 일정한 문장을 출력한다. 다른 오류를 넓게 잡아 숨기지 않으므로 프레임 설정이나 의존성 문제는 별도로 드러난다. 마지막 finally는 실행 중 오류가 발생해도 ROS 문맥을 종료한다.

실행 결과

두 파일을 같은 디렉터리에 저장한다. 다음 명령은 경고를 오류로 취급하여 문법 컴파일을 확인한다. 성공하면 출력이 없다. 컴파일은 import를 실행하지 않으므로 ROS가 없는 환경에서도 두 파일의 문법을 확인할 수 있다. 실제 ROS 의존성 확인은 두 번째 파일을 실행할 때 이루어진다.

python3 -W error -m py_compile time_frames.py tf2_time_query.py

순수 Python 예제의 실행 명령과 예상 출력은 다음과 같다.

python3 time_frames.py
at 10 s: x=3.200
latest: x=5.200
base_link at 12 s: x=0.200
at 13 s: extrapolation

ROS 2 Jazzy의 rclpy, geometry_msgs, tf2_ros, tf2_geometry_msgs를 불러올 수 있는 셸에서는 다음과 같이 실행한다. 프로그램이 출력하는 네 줄은 순수 Python 예제와 같다. 이 예제는 변환을 직접 주입하므로 별도의 변환 발행 노드나 메시지 수신 대기가 필요하지 않다.

python3 tf2_time_query.py
at 10 s: x=3.200
latest: x=5.200
base_link at 12 s: x=0.200
at 13 s: extrapolation

첫 줄과 둘째 줄의 2m 차이는 그동안 이동한 차체 거리다. 셋째 줄은 첫 줄의 점을 12초 차체 위치에서 다시 표현한 값이다. 마지막 줄은 미래 위치를 추정하는 대신 기록 부족을 드러낸다. 제시한 출력은 코드와 계산에 따른 예상값이며, 이 원고에서는 도구를 사용한 실행 검증을 수행하지 않았다.

실무에서 자주 틀리는 것

관측 시각을 최신 시각으로 바꾼다

다음 코드는 메시지의 지연을 없애지 않는다. 과거 관측을 최신 센서 위치에서 측정한 것처럼 해석하게 만든다. 아래 조각의 buffer와 point는 완성 코드와 같은 역할이다.

# 틀린 코드
transform = buffer.lookup_transform(
    "odom", point.header.frame_id, ros_time(0)
)

# 고친 코드
transform = buffer.lookup_transform(
    "odom",
    point.header.frame_id,
    Time.from_msg(point.header.stamp),
)

메시지의 stamp가 0이라면 관측 시각을 변환했다는 생각과 달리 최신 조회가 된다. 드라이버가 유효한 관측 시각을 넣는지 확인하고, 시각이 없는 데이터를 어떻게 처리할지 별도 정책을 정해야 한다.

외삽 오류를 좌표 원점으로 바꾼다

변환 실패를 x=0으로 바꾸면 “아직 위치를 계산할 수 없음”이 “원점에 물체가 있음”으로 바뀐다. 실패한 관측은 보류하거나 폐기할 수 있도록 결과를 구분한다. 고친 함수는 성공한 좌표 또는 None을 반환한다.

# 틀린 코드
try:
    transform = buffer.lookup_transform(
        "odom", "sensor", ros_time(13)
    )
except ExtrapolationException:
    x = 0.0

# 고친 코드
def try_project(buffer, point):
    try:
        transform = buffer.lookup_transform(
            "odom",
            point.header.frame_id,
            Time.from_msg(point.header.stamp),
        )
    except ExtrapolationException:
        return None
    return do_transform_point(point, transform)

호출자는 None을 받았을 때 대기열에 보관할지 결정한다. 대기열에는 크기와 보류 시간을 제한해야 한다. 과거 기록이 이미 사라진 경우와 변환이 곧 도착할 수 있는 경우를 오류 정보로 나누면 불필요한 재시도를 줄일 수 있다.

변환 방향을 뒤집고 장착 오프셋을 더한다

장착 위치가 차체 기준 전방 0.2m라면 부모가 base_link이고 자식이 sensor인 변환에 +0.2를 넣는다. 부모와 자식을 뒤집은 채 같은 값을 유지하면 다른 관계를 표현한다.

# 틀린 코드
mount_tf = make_transform("sensor", "base_link", 10, 0.2)

# 고친 코드
mount_tf = make_transform("base_link", "sensor", 10, 0.2)

이 예제처럼 회전이 없으면 반대 방향 변환의 이동량은 −0.2m다. 회전이 있으면 이동량도 역회전해야 하므로 부호만 바꿔서는 안 된다. 센서 값의 의미를 확인할 때는 “센서 원점 좌표 0을 변환하면 차체에서 어디가 되는가”를 먼저 계산한다.

두 시각의 질문을 한 시각 조회로 해결한다

다음의 첫 조회는 12초 센서를 12초 차체로 옮기는 장착 관계만 반환한다. 10초에 관측한 점을 12초 차체에서 보려는 목적에는 원본 시각이 빠져 있다.

# 틀린 코드
transform = buffer.lookup_transform(
    "base_link", "sensor", ros_time(12)
)

# 고친 코드
transform = buffer.lookup_transform_full(
    target_frame="base_link",
    target_time=ros_time(12),
    source_frame="sensor",
    source_time=ros_time(10),
    fixed_frame="odom",
)

원본 시각과 목표 시각의 의미를 문장으로 적고 코드와 맞춰 보면 인자 순서를 확인하기 쉽다. 연결 기준인 odom에 필요한 두 시각의 동적 기록이 모두 있어야 한다는 점도 함께 확인한다.

한눈에 보기

센서 좌표를 처리할 때 확인할 항목과 대응 방법
항목확인할 의미대응 방법예제 값
관측 시각좌표를 측정한 때메시지 stamp로 조회10초
장착 변환차체에 대한 센서 위치·방향고정 장착이면 정적으로 표현전방 0.2m
보간기록 사이의 요청기록 간격과 움직임 확인11초 차체 x=2m
과거 외삽가장 오래된 기록보다 앞선 요청보관 기간·시작 시점 확인9초 요청
미래 외삽최신 기록보다 뒤의 요청전달 지연·시간 기준 확인13초 요청
두 시각 조회과거 점을 이후 프레임으로 표현두 시각과 고정 프레임 지정10초 센서 → 12초 차체

API의 인자와 예외에 관한 추가 확인은 ROS 2 Jazzy의 tf2_ros Buffer 참조에서 할 수 있다. 실제 센서에 적용할 때는 프레임 연결, 시간 기준, 기록 범위, 장착 방향을 차례로 확인한다.

연습 문제

  1. 센서 장착 위치를 전방 0.5m로 바꾼다. 관측값과 차체 기록은 그대로 둘 때 10초의 odom 좌표, 최신 변환을 적용한 좌표, 12초 차체 기준 좌표를 각각 계산한다.
  2. 기본 예제에서 11초에 센서 기준 x=2m인 점을 관측했다고 가정한다. 그 점의 odom 좌표를 계산한다. 같은 버퍼에서 9초를 요청하면 왜 실패하는지 설명한다.
  3. 차체에 대한 센서 방향을 반시계 방향 90도로 바꾸고 장착 위치는 전방 0.2m로 유지한다. 10초에 센서 기준 (2, 0)을 관측했을 때 odom 좌표를 계산한다. 순수 Python 코드에서 바꿀 부분도 적는다.
  4. 센서 메시지의 시각이 25.000초이고 경로의 동적 기록은 24.980초까지만 도착했다. 메시지를 처리하는 쪽에서 취할 수 있는 대응과, 최신 변환으로 즉시 대체했을 때 달라지는 의미를 설명한다.

정답과 해설

  1. 10초의 odom 좌표는 1.0+0.5+2.0=3.5m다. 최신 변환을 적용하면 3.0+0.5+2.0=5.5m다. 12초 차체 기준 좌표는 3.5−3.0=0.5m다. 장착 위치를 바꿔도 최신 조회와 관측 시각 조회의 차이는 차체가 이동한 2m로 유지된다.

  2. 11초의 차체 위치는 두 기록 사이의 중간인 x=2m다. 관측점의 odom 좌표는 2.0+0.2+2.0=4.2m다. 9초는 첫 동적 기록인 10초보다 앞서 있으므로 과거 방향 외삽 오류가 된다. 정적 장착 변환이 있어도 차체의 9초 위치 기록을 대신할 수 없다.

  3. 센서의 (2, 0)은 차체에서 회전 후 (0, 2)가 된다. 장착 위치를 더하면 (0.2, 2), 10초 차체 위치를 더하면 odom에서 (1.2, 2)가 된다. 첫 파일에서 math의 pi를 추가로 가져오고 mount = Pose2(0.2, 0.0, pi / 2)로 바꾼다. 기존 x좌표를 확인하는 assert도 새 기대값에 맞춰 수정해야 한다.

  4. 요청 시각이 최신 기록보다 0.020초 앞서 있으므로 미래 방향 외삽이 발생할 수 있다. 관측을 제한된 시간 동안 보관하면서 해당 시각을 지원하는 변환이 도착한 뒤 다시 조회할 수 있다. 두 발행자의 시간 기준도 확인한다. 최신 변환으로 즉시 대체하면 25.000초 관측을 더 이른 센서 위치에서 얻은 값처럼 해석하게 된다. 그 차이를 허용할지는 이동 속도와 필요한 위치 정확도를 근거로 별도로 판단해야 한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.