tf 프레임 원점이 base_link에서 어긋나 보일 때 변환 방향부터 의심해야 할까: 1 m 오프셋을 반대로 발행하면 2 m가 벌어지는지 계산하기
RViz에 로봇을 띄웠더니 laser 프레임의 축이 base_link에서 엉뚱하게 떨어진 곳에 그려진다. tf 프레임 원점이 base_link에서 어긋나 보일 때 변환 방향부터 의심해야 할까? 회전이 없는 변환이라면 계산은 짧다. 부모 기준 +1 m에 있어야 할 자식 프레임을 거꾸로 발행하면 자식은 −1 m에 놓이고, 의도한 자리와 정확히 2 m 벌어진다. ROS 2 Jazzy의 tf2에 실제로 발행한 뒤 다시 읽어 본 값도 5번의 실행에서 모두 그랬다.
그런데 같은 실측이 제목의 전제를 세 군데에서 좁혔다. 2배는 부모·자식을 바꾼 경우와 이동량 부호만 뒤집은 경우에 똑같이 나오므로, 숫자 하나로는 둘을 가를 수 없다. 회전이 섞이면 부모·자식을 바꿔도 1배나 0배가 나왔다. 그리고 base_link가 이미 odom의 자식인 상태에서 base_link→laser를 거꾸로 적으면, laser는 2배 떨어진 곳에 놓이는 대신 odom이 속한 트리에서 아예 떨어져 나갔다. 그러니 순서는 “방향부터 의심”보다 “어긋남÷오프셋, 트리 모양, 조회 오류 메시지를 먼저 읽고 방향 가설을 빨리 남기거나 버리기”에 가깝다.
따라오는 데는 덧셈·뺄셈과 2×2 회전행렬 곱셈, 그리고 tf 트리에 부모와 자식이 있다는 정도의 감각이면 된다. tf2가 센서 데이터 전달과 어떻게 다른 역할을 맡는지는 ROS 2와 tf2: 센서 데이터 전달과 좌표 변환을 구분하는 법에 따로 정리해 두었다. 글의 숫자는 두 종류다. 손계산은 Python 표준 라이브러리로 검산한 산술 예제이고, 실측은 네트워크를 끊은 ROS 2 Jazzy 컨테이너에서 tf2가 돌려준 좌표다. RViz 화면과 실물 로봇은 재지 않았다.
Transform 메시지의 숫자는 자식이 부모 안 어디에 있는지 적는다
tf2는 로봇의 여러 좌표계 사이 관계를 시간과 함께 저장하고 계산해 주는 ROS 2 라이브러리다. 여기서 좌표 프레임(coordinate frame)은 위치를 재는 기준점과 x·y·z 축의 묶음을 말한다. base_link는 로봇 몸체에 고정된 프레임이고, 바퀴 회전 등으로 추정한 이동 기준인 odom 프레임이 보통 그 부모가 된다(REP 105의 map→odom→base_link 구성).
프레임 사이 관계는 geometry_msgs/msg/TransformStamped 메시지 하나로 발행한다. header.frame_id가 부모 프레임(parent frame), child_frame_id가 자식 프레임(child frame)이고, 그 안의 변환(transform)은 회전(rotation)과 이동(translation)의 묶음이다. 메시지 정의의 주석은 이 값을 “Translation and rotation in 3-dimensions of child_frame_id from header.frame_id”라고 적는다(TransformStamped.msg L1-L18). 부모 프레임 안에서 자식 원점이 어디에 있고 어느 쪽을 보는지를 적는다는 뜻이다. URDF의 joint <origin>도 같은 방향, 즉 부모 링크에서 자식 링크로 가는 변환이다(URDF joint 명세).
헷갈림은 같은 관계를 두 방향으로 쓸 수 있다는 데서 온다. tf2 문서는 프레임 자체를 옮기는 값을 frame formulation(프레임 표현), 어떤 프레임에서 잰 데이터를 다른 프레임의 숫자로 바꾸는 값을 data formulation(데이터 표현)이라 부르고, 둘이 “directly inverse”, 곧 서로의 역이라고 설명한다. 메시지에 적는 값은 frame formulation이라는 점, 그리고 발행한 변환을 디버깅할 때 이 차이를 기억하라는 당부도 같은 문단에 있다(About tf2 L46-L57).
바닥(A)과 그 1 m 위 선반(B)으로 그려 보면 쉽다. “선반은 바닥에서 위로 1 m”라는 메모가 frame formulation이다. 선반 위 0.5 m 높이에 놓인 컵을 바닥 기준 숫자로 바꾸면 1.5 m가 되고, 반대로 바닥 기준 1.5 m를 선반 기준으로 바꾸면 0.5 m가 된다. tf2 문서의 1차원 예도 같다. A가 B보다 1 m 아래면 A→B 변환은 양수이고, B에서 잰 데이터를 A로 옮기면 높이가 더해진다(About tf2 L80-L93). 선반 비유가 통하는 곳은 회전이 없는 경우까지다. 실제 프레임은 돌아가 있을 수 있고, 그러면 역변환이 부호 뒤집기로 끝나지 않는다. 이 차이는 뒤의 회전 절에서 다시 계산한다.
lookup_transform이나 transform()으로 데이터를 옮기는 코드에서는 tf2가 트리를 거슬러 올라가며 역변환을 대신 처리한다. 방향 실수가 생기는 곳은 사람이 숫자를 직접 적는 지점이다. static_transform_publisher 인자, 직접 채운 TransformStamped, URDF의 <origin>이 여기에 해당한다.
손으로 뒤집어 보면 +1 m는 −1 m가 되고 차이는 2 m다
의도한 발행은 (frame_id=A, child_frame_id=B, z=+1 m)다. B 원점은 A 기준 z=+1 m에 있다. 거꾸로 적는 방법은 두 가지다.
- 부모·자식 교환: (frame_id=B, child_frame_id=A, z=+1 m). 이제 메시지는 “A 원점이 B 기준 +1 m”라고 말하므로, A에서 본 B 원점은 −1 m다.
- 이동 부호 반전: (frame_id=A, child_frame_id=B, z=−1 m). B 원점은 그대로 −1 m다.
어느 쪽이든 의도한 +1 m와 실제 −1 m의 차이는 1 − (−1) = 2 m다. 회전이 없으면 B에 붙은 모든 점이 같은 벡터만큼 밀린다. B 위 z=0.5 m의 점은 A 기준 1.5 m에 있어야 하지만 −0.5 m에 놓여, 역시 2 m 벌어진다. base_link→laser를 x=0.2 m로 적어야 할 것을 거꾸로 적으면 laser는 −0.2 m에 놓이고 벌어짐은 0.4 m, 비율은 2다.
반대로 어긋남이 오프셋과 같은 1배라면 회전 없는 방향 실수로는 설명이 안 된다. 예를 들어 0.3 m 떨어져 있어야 할 프레임이 0 m에 있으면 어긋남은 0.3 m, 비율은 1이다. 값을 빠뜨렸거나 잘못 잰 쪽을 먼저 봐야 한다.
이 계산은 10개 케이스로 묶어 검산했다. 손계산 코드는 example.py, 기대값과 실행 기록은 verification.json에 있다. Python 3.12.3에서 python3 -I example.py로 실행해 종료 상태 0을 기록했고, 각 케이스는 math.isclose(..., abs_tol=1e-9)로 기대값과 대조한다. 이것은 산술 검산일 뿐 tf2나 로봇을 실행한 결과가 아니다.

tf2에 실제로 발행하고 다시 읽은 값
산술은 tf2의 정의를 맞게 읽었다는 전제 위에 서 있다. 그 전제를 확인하려고 변환을 실제 /tf_static 토픽으로 발행하고 tf2가 돌려주는 좌표를 다시 읽는 스크립트를 따로 돌렸다. 손계산 단계에서 답을 내지 못한 질문, 즉 이미 부모가 있는 프레임을 거꾸로 적으면 어떻게 되는지도 함께 쟀다.
발행 방식은 스크립트의 published() 함수가 정한다. make(stamp, parent, child, ...)는 header.frame_id=parent, child_frame_id=child로 메시지를 채운다.
def published(sid, xyz, yaw, kind, stamp):
parent, child = f"{sid}_parent", f"{sid}_child"
if kind == "intended":
return make(stamp, parent, child, xyz, yaw)
if kind == "swap":
return make(stamp, child, parent, xyz, yaw)
if kind == "sign":
return make(stamp, parent, child, tuple(-c for c in xyz), yaw)
if kind == "omit":
return make(stamp, parent, child, (0.0, 0.0, 0.0), yaw)
측정 방법: 노드 하나가 정적 변환(static transform, 시간에 따라 바뀌지 않는다고 보고 /tf_static으로 한 번 발행하는 변환) 21개를 보낸다. 시나리오 19개와 트리 충돌 비교용 2개다. 같은 노드의 TransformListener가 토픽으로 받은 값을 Buffer에 넣고, 시나리오마다 buffer.lookup_transform(의도한 부모, 의도한 자식, Time())을 호출한다. 돌려받은 translation이 “부모 안에서 본 자식 원점”(org)이다. 이 값과 의도한 이동량의 거리(gap), gap÷오프셋(ratio)은 실행 중에 계산했다. 트리에서 의도한 자식의 부모(cpar)는 buffer.all_frames_as_yaml()에서 읽었다.
측정 환경: 이미지 ros:jazzy-ros-base(digest ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca), ROS 2 Jazzy, Python 3.12.3, rclpy 7.1.12-1noble.20260902.053513, RMW(ROS 2가 노드 사이 메시지를 실제로 주고받는 통신 구현층, ROS middleware)는 기본값 rmw_fastrtps_cpp, 네트워크 없음, 1 CPU·1 GiB. 5회 실행이 모두 종료 코드 0이었고, 아래 숫자는 5회 모두 같았다.
회전이 없는 경우의 예측(plan.md)과 실측:
| 시나리오 | 발행한 값 | 예측 | 조회한 자식 원점 | gap (m) | ratio | 트리에서 자식의 부모 |
|---|---|---|---|---|---|---|
| z1m 의도대로 | 부모→자식 z=+1.0 | z=+1 | (0.0, 0.0, 1.0) | 0.0 | 0.0 | 의도한 부모 |
| z1m 부모·자식 교환 | 자식→부모 z=+1.0 | z=−1, 비율 2 | (0.0, 0.0, -1.0) | 2.0 | 2.0 | 없음(루트) |
| z1m 부호 반전 | 부모→자식 z=−1.0 | z=−1, 비율 2 | (0.0, 0.0, -1.0) | 2.0 | 2.0 | 의도한 부모 |
| laser 의도대로 | 부모→자식 x=0.2 | x=0.2 | (0.2, 0.0, 0.0) | 0.0 | 0.0 | 의도한 부모 |
| laser 부모·자식 교환 | 자식→부모 x=0.2 | x=−0.2, 비율 2 | (-0.2, 0.0, 0.0) | 0.4 | 2.0 | 없음(루트) |
| laser 부호 반전 | 부모→자식 x=−0.2 | x=−0.2, 비율 2 | (-0.2, 0.0, 0.0) | 0.4 | 2.0 | 의도한 부모 |
| laser 오프셋 누락 | 부모→자식 (0, 0, 0) | 비율 1 | (0.0, 0.0, 0.0) | 0.2 | 1.0 | 의도한 부모 |
자식 프레임 위의 점도 같이 밀렸다. z1m에서 자식 기준 z=0.5인 점은 의도대로면 부모 기준 z=1.5, 교환·부호 반전에서는 z=-0.5였다(pt_gap 2.0). laser에서 자식 기준 x=0.5인 점은 의도대로 x=0.7, 거꾸로 x=0.3이었다(pt_gap 0.4).
1회차 원본 출력 일부(stdout 그대로):
{"s": "z1m_swap", "yaw": 0.0, "off": 1.0, "org": [0.0, 0.0, -1.0], "gap": 2.0, "ratio": 2.0, "cpar": "root", "pt": [0.0, 0.0, 0.5], "pt_par": [0.0, 0.0, -0.5], "pt_gap": 2.0}
{"s": "z1m_sign", "yaw": 0.0, "off": 1.0, "org": [0.0, 0.0, -1.0], "gap": 2.0, "ratio": 2.0, "cpar": "parent", "pt": [0.0, 0.0, 0.5], "pt_par": [0.0, 0.0, -0.5], "pt_gap": 2.0}
{"s": "laser02_swap", "yaw": 0.0, "off": 0.2, "org": [-0.2, 0.0, 0.0], "gap": 0.4, "ratio": 2.0, "cpar": "root", "pt": [0.5, 0.0, 0.0], "pt_par": [0.3, 0.0, 0.0], "pt_gap": 0.4}
{"s": "laser02_omit", "yaw": 0.0, "off": 0.2, "org": [0.0, 0.0, 0.0], "gap": 0.2, "ratio": 1.0, "cpar": "parent", "pt": [0.5, 0.0, 0.0], "pt_par": [0.5, 0.0, 0.0], "pt_gap": 0.2}
재현 스크립트와 5회분 원본 출력은 experiments/tf-frame-offset-transform-direction-double-error에 있다. 파일 단위로는 experiment.py, results.json, 예측과 두 번의 시험 실행에서 고친 점을 함께 적은 plan.md다.
회전 없는 케이스는 예측과 실측이 그대로 일치했다. 2배는 손계산이 말한 대로 나왔고, 오프셋을 빠뜨린 대조군은 1배였다. 이 범위에서 tf2의 frame formulation을 읽은 방식은 맞았다고 볼 수 있다.
직접 돌려 보려면 Docker만 있으면 된다. 아래 명령은 하네스가 실행한 고정 이미지를 digest로 지정한다.
mkdir tf-direction-check && cd tf-direction-check
curl -fsSLO https://raw.githubusercontent.com/sungpyo9053/blog/main/experiments/tf-frame-offset-transform-direction-double-error/experiment.py
docker run --rm --network none --cpus 1 --memory 1g -v "$PWD":/work ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca bash -c "source /opt/ros/jazzy/setup.bash && python3 /work/experiment.py"
정상이라면 JSON 줄이 차례로 찍히고 마지막 줄이 {"event": "done", ...}이며 종료 코드는 0이다. 시나리오 줄의 org·gap·ratio가 위 표와 같으면 같은 결과를 얻은 것이다. 정적 변환이 리스너에 끝내 도착하지 않으면 스크립트가 static transforms never reached the listener로 멈추는데, 이것은 실패로 판정한다. 하네스는 여기에 읽기 전용 마운트와 /tmp 작업 디렉터리를 더해 실행했고, 스크립트는 파일을 쓰지 않는다.
2배는 두 실수에서 똑같이 나오고, 트리 모양이 둘을 가른다
표에서 교환과 부호 반전은 org·gap·ratio가 한 자리도 다르지 않다. 다른 칸은 마지막 열 하나다. 부호를 뒤집은 경우에는 의도한 자식의 부모가 그대로 의도한 부모였고, 부모·자식을 바꾼 경우에는 의도한 자식이 부모 없는 루트가 됐다.
이유는 tf2가 트리를 저장하는 방식에 있다. REP 105는 각 프레임이 부모 하나와 여러 자식을 갖는다고 정하고, tf2 구현은 변환을 child_frame_id 쪽 저장소에 넣으면서 기록마다 부모 frame_id를 함께 남긴다(buffer_core.cpp L271-L292). 부모·자식을 바꾸면 “누가 누구의 부모인가”가 바뀌고, 부호만 틀리면 트리는 멀쩡한 채 숫자만 틀린다.
그래서 2배를 확인한 다음에는 트리를 본다. 공식 튜토리얼은 ros2 run tf2_tools view_frames로 트리 그림을 보는 방법을 안내한다(Introduction to tf2 L86-L123). 문서상 Linux 전용이고, 이 글에서는 실행하지 않았다. 교환이라면 그림에서 화살표 방향이 뒤집혀 있고, 부호 실수라면 그림은 정상이다.
회전이 섞이면 1배도, 0배도 방향 실수일 수 있다
회전 R과 이동 t로 된 변환 (R, t)의 역변환(inverse transform)은 (Rᵀ, −Rᵀt)다. Rᵀ는 R의 전치, 즉 반대 방향 회전이다. z축으로 90° 돌아간 센서를 x=0.2 m에 달았다고 하자. R = [[0, −1], [1, 0]]이면 Rᵀ = [[0, 1], [−1, 0]]이고, Rᵀt = (0, −0.2), 따라서 역 이동량은 (0, 0.2)다. 이동 부호만 뒤집은 (−0.2, 0)과는 방향부터 다르다. 이 두 성분도 앞의 verification.json에 케이스로 들어 있다. 회전과 이동이 섞인 좌표 표현이 왜 부호 하나로 바뀌지 않는지는 피지컬 AI 좌표계: 같은 물체의 위치가 다르게 보이는 이유에서 다른 예로 다뤘다.
tf2 실측에서는 이동량 t=(0.2, 0, 0)을 고정하고 z축 회전(yaw)만 바꿨다. 부모·자식 교환과 부호 반전 외에 두 가지를 더 넣었다. 하나는 부모·자식을 바꾸되 (Rᵀ, −Rᵀt)를 정확히 계산해 적은 경우이고, 다른 하나는 부모·자식을 바꾸면서 회전 각도와 이동 부호만 뒤집어 적은 ‘어설픈 역’이다.
회전이 있는 경우의 ratio, 예측(plan.md)과 실측(5회 동일):
| yaw | 부모·자식 교환 | 부호 반전 | 교환 + 정확한 역 (Rᵀ, −Rᵀt) | 교환 + 각도·부호만 뒤집음 |
|---|---|---|---|---|
| 90° | 예측 약 1.414 / 실측 1.414214 | 예측 2 / 실측 2.0 | 예측 0 / 실측 0.0 | 예측 약 1.414 / 실측 1.414214 |
| 120° | 예측 1 / 실측 1.0 | 예측 2 / 실측 2.0 | 예측 0 / 실측 0.0 | 예측 약 1.732 / 실측 1.732051 |
| 180° | 예측 0 / 실측 0.0 | 예측 2 / 실측 2.0 | 예측 0 / 실측 0.0 | 예측 2 / 실측 2.0 |
90° 교환에서 조회한 자식 원점은 (0.0, 0.2, 0.0)으로, 위의 손계산 (0, 0.2)와 같았다. 120° 교환은 (0.1, 0.173205, 0.0)이었고 gap은 오프셋과 같은 0.2였다. 180° 교환은 (0.2, 0.0, 0.0), 의도한 자리 그대로였다. 트리에서는 교환한 시나리오 모두 의도한 자식이 루트였고, 부호 반전은 모두 의도한 부모 밑에 있었다.
{"s": "rot120_swap", "yaw": 120.0, "off": 0.2, "org": [0.1, 0.173205, 0.0], "gap": 0.2, "ratio": 1.0, "cpar": "root"}
{"s": "rot180_swap", "yaw": 180.0, "off": 0.2, "org": [0.2, 0.0, 0.0], "gap": 0.0, "ratio": 0.0, "cpar": "root"}
{"s": "rot120_swap_neg_t", "yaw": 120.0, "off": 0.2, "org": [-0.1, 0.173205, 0.0], "gap": 0.34641, "ratio": 1.732051, "cpar": "root"}
이번에도 예측대로 나왔다. 교환의 비율은 조사 단계에서 유도해 plan.md로 옮겨 둔 2·cos(θ/2) 꼴을 세 각도에서 그대로 따랐다. 읽어 낼 것은 세 가지다.
첫째, 회전이 있으면 “1배니까 방향 실수가 아니다”라는 판정이 깨진다. 120°로 단 센서는 부모·자식을 바꿨는데도 정확히 1배 어긋났다. 둘째, 180°에서는 위치가 제자리였다. yaw 180°와 −180°는 같은 회전이므로 계산상 방향도 같아진다(실험은 원점만 쟀다). 이 경우 화면의 위치로는 실수를 찾을 수 없고, 트리 모양만이 단서로 남는다. 셋째, 부호 반전은 회전과 상관없이 늘 2배였고 트리도 정상이었다. 180°의 ‘어설픈 역’도 2배가 나왔으니 회전이 있을 때 2배만으로 부호 실수라고 단정할 수는 없지만, 2배에 정상 트리가 겹치면 이동 부호가 가장 유력하다.
정확한 역을 계산해 적은 경우는 세 각도 모두 위치가 맞았다. 각도와 부호만 뒤집은 ‘어설픈 역’은 맞지 않았다. 다만 정확한 역이라도 트리에서는 의도한 자식이 여전히 루트였다. 위치만 맞췄을 뿐 부모·자식 관계는 뒤집힌 채라는 뜻이고, 바로 다음 절의 상황, 곧 그 프레임에 이미 다른 부모가 있는 경우에는 같은 종류의 충돌이 생길 수 있다. 이 마지막 문장은 회전 케이스로 직접 잰 것이 아니라 다음 절 결과에서 끌어낸 추론이다.
base_link에 odom 부모가 있으면 2배 대신 트리가 끊긴다
앞의 시나리오는 프레임 쌍마다 이름을 따로 써서, 거꾸로 적은 프레임에 다른 부모가 없었다. 실제 로봇은 다르다. base_link는 보통 odom의 자식이고, 여기에 laser→base_link를 거꾸로 적으면 base_link가 부모 둘(odom, laser)을 갖게 된다. 손계산만으로는 이때 무슨 일이 생기는지 답할 수 없어서, 정상 쌍(ok)과 충돌 쌍(clash)을 같은 조건으로 놓고 쟀다.
첫 시험 실행에서는 odom 동적 변환이 도착하기 전에 표본을 세기 시작해 첫 표본 하나가 LookupException(“odom” 없음)으로 섞였다. 그래서 odom이 트리에 나타난 뒤부터 세도록 고쳤고, 도착 전의 부모는 별도 줄로 남겼다. 아래는 고친 스크립트의 결과다.
조건: odom→ok_base, odom→clash_base를 동적 변환(/tf)으로 0.05초마다 x=1.0 m에 발행했다. 정적 변환은 ok 쪽에 ok_base→ok_laser x=0.2 m(정상 방향), clash 쪽에 clash_laser→clash_base x=0.2 m(거꾸로)를 보냈다. odom이 트리에 나타난 뒤 3초 동안 매 루프에서 lookup_transform("odom", "<case>_laser", Time())을 시도했다.
| 비교 | ok (정상 방향) | clash (거꾸로) |
|---|---|---|
| odom 도착 전 base의 부모 | 없음 | clash_laser |
| odom 도착 후 base의 부모 | odom (120/120) | odom (120/120) |
| odom→laser 조회 성공 | 120/120 | 0/120 |
| odom 기준 laser x | 1.2 (최소·최대 모두) | 조회 불가 |
| 예외 | 없음 | ConnectivityException 120회 |
5회 실행 모두 같은 결과였다. clash 쪽의 첫 예외 메시지(1회차 원본):
{"event": "tree_clash_sample", "case": "clash", "odom_to_base_x_m": 1.0, "static_offset_m": 0.2, "laser_x_in_odom_min": null, "laser_x_in_odom_max": null, "tries": 120, "success": 0, "parents": {"odom": 120}, "errors": {"ConnectivityException": 120}, "first_error": "Could not find a connection between 'odom' and 'clash_laser' because they are not part of the same tree.Tf has two or more unconnected trees.", "t_s": 3.15}
이 실행에서 tf2는 clash_base의 부모를 처음엔 clash_laser로 기록했다가, odom의 동적 변환이 들어오자 odom으로 기록했다. 그 뒤로 clash_laser에서 clash_base로 가는 연결은 한 번도 조회되지 않았고, laser는 odom 쪽 트리와 분리된 별도의 트리가 됐다. 자식마다 부모 하나를 붙여 저장하는 앞 절의 구조와 맞아떨어지는 결과다. laser가 “2배 떨어진 곳”에 있는 것이 아니라, odom에서는 아예 찾을 수 없는 상태가 된 것이다. 이때 RViz가 laser 축을 어떻게 그리는지는 재지 않았다.
실전에서 읽는 법도 여기서 달라진다. odom이 돌고 있는 로봇에서 센서 프레임이 깔끔하게 2배 떨어져 보이면, 트리는 정상이고 이동 부호가 틀렸을 가능성을 먼저 본다. 반대로 not part of the same tree가 뜨면 방금 적은 정적 변환의 child_frame_id가 이미 다른 부모를 가진 프레임인지 확인한다. 부모·자식 교환이 2배로 드러나는 것은 의도한 부모가 루트일 때, 예컨대 odom을 띄우기 전 base_link를 기준으로만 모델을 볼 때다. 이 구분은 위 측정 사례들을 조합한 해석이고, 정적 변환을 먼저 보내고 동적 변환을 나중에 보낸 이번 순서와 Jazzy 구현에서 관측한 것이다. 다른 순서나 배포판에서도 같다고 단정하지 않는다.

2배 판정이 답하지 못하는 어긋남
비율이 1이거나 2도 1도 아니면, 그리고 회전이 없다면 방향은 우선순위에서 내려간다. 값 자체가 틀린 곳을 본다. URDF joint <origin>에 잘못 잰 숫자가 들어갔을 수도 있고, 로봇 모델의 mesh 원점이 링크 프레임 원점과 다를 수도 있다. 이 글의 출발점이 된 Robotics Stack Exchange 질문(“Tf frame origin is offset from the actual base_link”)도 채택 답변이 지목한 원인은 변환 방향이 아니었다. SolidWorks 조립에서 부품이 조립 원점에 맞물리지 않아 URDF로 옮긴 원점이 어긋났을 가능성이었다. 축의 부호가 다른 카메라 optical 프레임은 ROS 카메라 _optical 프레임 축 부호 확인, 타임스탬프가 어긋나 변환을 못 찾는 경우는 Message Filter dropping message 타임스탬프 글이 이어지는 경로다.
측정 범위도 좁다. 회전은 z축 yaw 하나, 이동은 회전축에 수직인 경우뿐이다. 명령행의 static_transform_publisher 인자 형식(--frame-id, --child-frame-id 등)은 Rolling 문서에서 읽었고 Jazzy에서 따로 확인하지 않았다. tf2_echo는 사용법 문구의 인자 이름(source, target)과 구현이 lookupTransform에 넘기는 순서가 헷갈리게 적혀 있다는 점을 소스 코드에서 읽었지만 실행해 확인하지는 않았으니, 방향 판정의 근거로 쓰기 전에 알려진 변환 하나로 출력 방향부터 맞춰 보는 편이 안전하다.
실물 로봇에서 tf를 고칠 때는 구동을 끈 정지 상태에서 먼저 위치를 확인하고 제조사의 안전 절차를 따른다.
확인 문제
1. odom 없이 base_link를 기준으로 모델을 띄웠다. base_link→imu는 회전 없이 x=+0.1 m여야 하는데 imu가 x=−0.1 m에 있다. 비율은 얼마이고, 다음에 무엇을 볼까?
답: 어긋남 0.2 m ÷ 오프셋 0.1 m = 2. 회전이 없으니 역변환이 발행됐다는 신호다. 트리 그림에서 imu가 base_link의 부모로 올라가 있으면 부모·자식 교환, 트리가 정상이면 이동 부호 실수다. base_link에 다른 부모가 없는 상황이라 교환도 2배로 드러날 수 있다.
2. yaw 120°로 장착한 센서가 정확히 오프셋만큼(1배) 어긋나 있다. 방향 실수를 배제해도 될까?
답: 안 된다. 실측에서 yaw 120°의 부모·자식 교환은 비율 1.0이었다. 회전이 있으면 비율로 판정하지 말고, 트리 모양을 보고 (Rᵀ, −Rᵀt)로 기대 위치를 직접 계산해 대조한다.
3. odom이 돌고 있는 로봇에서 lookup_transform("odom", "laser", ...)이 “not part of the same tree”로 실패한다. 의심할 곳은?
답: laser 관련 정적 변환의 child_frame_id가 base_link처럼 이미 부모가 있는 프레임으로 적혀 있지 않은지 본다. 이번 측정에서 laser→base로 거꾸로 적은 경우가 정확히 이 메시지를 냈다. frame_id 철자가 틀려 laser가 존재하지 않는 부모 밑에 붙어도 트리는 갈라질 수 있으니 이름도 함께 본다. 이 경우는 따로 재지 않았다.
자기 로봇의 정적 변환 하나로 점검하는 순서
- 의심 가는 정적 변환 하나를 고르고, launch 파일·코드·URDF에서 frame_id(부모), child_frame_id(자식), 이동량(부호 포함), 회전을 종이에 적는다.
- 설계 의도대로 “부모 기준 자식 원점이 있어야 할 좌표”를 하나 적는다. 예를 들어 base_link 기준 laser는 x=+0.2 m.
- 실제 위치를 읽어 어긋남÷|오프셋|을 계산한다. 조회 자체가 실패하면 오류 메시지를 적는다.
- 아래 표로 다음 행동을 고른다.
| 관측 | 회전 | 먼저 할 일 |
|---|---|---|
| 비율 2, 트리 정상 | 무관 | 이동량 부호를 고친다 |
| 비율 2, 의도한 자식이 부모 위로 올라감 | 없음 | frame_id와 child_frame_id를 바로잡는다 |
| 비율 1 | 없음 | 방향 가설을 버리고 joint origin, mesh·CAD 원점, 측정값을 본다 |
| 비율 1, 0 또는 그 밖의 값 | 있음 | 비율로 판정하지 않는다. 트리를 보고 (Rᵀ, −Rᵀt)로 직접 계산한다 |
not part of the same tree |
무관 | child_frame_id가 이미 다른 부모를 가진 프레임인지, frame_id 철자가 맞는지 본다 |
방향 실수는 숫자 하나와 트리 그림 한 장으로 빠르게 남기거나 버릴 수 있는 가설이다. 첫 점검 항목으로 둘 가치는 거기서 나온다. 반대로 비율이 맞지 않으면 그 가설을 붙들고 있을 이유도 없다. 위 표의 2배 줄과 1배 줄은 회전이 없는 프레임에서만 쓰고, 회전이 있는 장착에서는 계산을 직접 하는 편이 결국 빠르다.
확인한 원문
확인일은 모두 2026-10-04이며, ROS 2 문서는 Rolling 브랜치의 고정 커밋 기준이다.
- About tf2 L46-L57 (frame/data formulation 역관계), L80-L93 (1차원 높이 예), Rolling 원문
- geometry_msgs/msg/TransformStamped.msg L1-L18
- Writing a static broadcaster (Python) L228-L248
- REP 105 — Coordinate Frames for Mobile Platforms
- tf2 buffer_core.cpp L271-L292
- tf2_echo.cpp L193-L204, buffer_core.hpp L137-L141 (코드 읽기, 미실행)
- URDF joint 명세