ROS 카메라 _optical 프레임으로 점을 옮길 때 축 부호가 맞는지 확인하기: 오른손 좌표계 행렬식과 기준점 하나로 매핑 검사
로봇 몸체 기준으로 적은 점 (x, y, z)를 카메라 _optical 프레임 좌표로 바꾸면 (−y, −z, x)가 된다. 앞으로 2 m, 왼쪽으로 0.5 m, 위로 0.3 m 떨어진 점은 (−0.5, −0.3, 2) m다. ROS 카메라 _optical 프레임으로 점을 옮길 때 축 부호가 맞는지 확인하기가 까다로운 이유는 식 자체보다 틀린 식을 거르는 검사 쪽에 있다. ‘x는 왼쪽, y는 위’로 잘못 짠 매핑은 같은 점을 (0.5, 0.3, 2)로 보내는데, 오른손 좌표계 행렬식은 +1이고 원점까지의 거리도 그대로다. 이 오류는 기대 좌표를 미리 적어 둔 기준점 하나와 비교할 때 비로소 드러난다.
부호를 외우지 않고 규약 문장에서 직접 유도하는 방법, 행렬식·거리·기준점 세 검사가 각각 어떤 오류를 잡고 놓치는지, 기준점을 고르는 조건을 차례로 다룬다. 벡터의 성분 표기, 내적(성분끼리 곱해 더한 값), 2×2 행렬식(ad − bc)을 알면 따라올 수 있다. 근거는 2026년 9월 27일에 대조한 ROS 규약 원문과 Python 3.12.3 산술 검산이며, ROS 노드나 tf2, 카메라 드라이버, 시뮬레이터, 실물 로봇을 실행한 결과는 아니다. 원점이 같고 축 방향만 다른 경우로 범위를 좁혔다.
한 카메라에 프레임이 두 개 붙는 이유
좌표 프레임(coordinate frame)은 원점 하나와 세 축의 방향을 묶은 기준이다. ROS에서는 메시지의 frame_id 문자열로 “이 숫자는 어느 기준으로 잰 값인가”를 표시한다. 같은 점도 기준이 바뀌면 숫자가 바뀌기 때문에, 프레임 이름 없이 좌표만 주고받으면 부호 실수가 조용히 끼어든다.
ROS의 좌표 규약 문서인 REP-103은 모든 좌표계가 오른손 좌표계여야 한다고 적고, 몸체 기준 축을 x 앞(forward), y 왼쪽(left), z 위(up)로 정한다(Chirality 원문, L102–L109). 이것을 이 글에서는 몸체 규약(body convention)이라고 부른다. 같은 문서의 Suffix Frames 절은 카메라에 흔히 _optical 접미사가 붙은 두 번째 프레임이 있고, 이 광학 프레임(optical frame)은 z 앞, x 오른쪽, y 아래를 쓴다고 적는다(L124–L132).
카메라 보정 정보를 담는 ROS 2 메시지 sensor_msgs/msg/CameraInfo.msg의 header 주석은 이 축을 이미지 쪽에서 다시 설명한다. frame_id는 카메라의 optical frame이어야 하고, 원점은 광학 중심, +x는 이미지의 오른쪽, +y는 이미지의 아래, +z는 이미지 평면 안쪽이다. 이미지 평면 안쪽은 카메라가 바라보는 방향이므로 REP-103의 “z 앞”과 같은 뜻이다. 화면의 가로는 오른쪽으로, 세로는 아래로 커지는 이미지 표기에 축을 맞춘 셈이다. 그래서 몸체 쪽 “앞”은 x인데 카메라 쪽 “앞”은 z가 되고, 둘 사이에 부호가 붙은 축 바꿈이 생긴다.
REP-103은 Informational 문서다. Exceptions 절은 모든 ROS 소프트웨어가 이 지침을 따르도록 요구하기에는 범위가 넓다고 밝히고, 다른 규약을 쓰려면 정당화하고 문서화하라고 적는다(L27–L32). 쓰고 있는 카메라 드라이버가 실제로 어떤 프레임 이름과 축을 내보내는지는 그 드라이버 문서에서 따로 확인해야 한다.

부호는 “오른쪽은 왼쪽의 반대”에서 나온다
부호를 외울 필요는 없다. optical 축 세 개가 몸체 기준으로 어느 방향인지 말로 적고, 그 말을 벡터로 바꾸면 된다.
- optical x는 오른쪽이다. 몸체 y가 왼쪽이므로 오른쪽은 −y다.
- optical y는 아래다. 몸체 z가 위이므로 아래는 −z다.
- optical z는 앞이다. 몸체 x가 앞이므로 그대로 +x다.
이 세 방향을 몸체 좌표의 단위벡터(길이 1인 방향 벡터)로 쓰면 x_opt = (0, −1, 0), y_opt = (0, 0, −1), z_opt = (1, 0, 0)이다. 어떤 점의 optical x 성분은 “그 점이 오른쪽으로 얼마나 가 있는가”이고, 이 값은 점과 x_opt의 내적으로 구한다. 세 축에 같은 계산을 하면 몸체 점 (x_b, y_b, z_b)의 optical 좌표는 (−y_b, −z_b, x_b)다.
예제 점 p = (2, 0.5, 0.3) m에 대입해 보면 다음과 같다.
- optical x = (2, 0.5, 0.3)·(0, −1, 0) = −0.5
- optical y = (2, 0.5, 0.3)·(0, 0, −1) = −0.3
- optical z = (2, 0.5, 0.3)·(1, 0, 0) = 2
결과 (−0.5, −0.3, 2) m를 말로 읽으면 “이미지 중심에서 왼쪽으로 0.5 m, 위로 0.3 m, 앞으로 2 m”다. 몸체 기준 설명과 같은 위치를 가리키므로 부호가 맞는지 문장으로도 되짚을 수 있다.
여기서 세 벡터를 어떻게 쌓느냐에 따라 행렬의 뜻이 달라진다. 세 벡터를 행으로 쌓은 3×3 행렬은 몸체 좌표를 optical 좌표로 바꾸는 행렬이다. 같은 벡터를 열로 쌓은 행렬(전치 행렬)은 optical 프레임의 자세를 몸체 기준으로 적은 것이며, 반대 방향인 optical 좌표를 몸체 좌표로 되돌린다. 열 쌓기 행렬에 (−0.5, −0.3, 2)를 넣으면 (2, 0.5, 0.3)이 나온다. 두 행렬의 행렬식은 모두 +1이라서 행렬식으로는 방향을 구별할 수 없다. 코드나 라이브러리에서 “회전 행렬”을 건네받았다면 어느 쪽인지부터 확인해야 한다. 이 글에서 3×3 행렬이라고 하면 행 쌓기, 즉 몸체→optical 변환을 가리킨다.
행렬식은 오른손인지 왼손인지만 알려 준다
오른손 좌표계(right-handed coordinate system)는 x축과 y축의 외적(cross product)이 z축과 같은 방향인 좌표계다. 오른손 엄지를 x, 검지를 y로 폈을 때 가운데손가락이 z를 가리키면 오른손이다. 반대로 축 하나의 방향만 뒤집으면 거울에 비친 왼손 좌표계가 되고, 이런 매핑을 거울상 오류(reflection)라고 부른다.
행렬식(determinant)은 이 손잡이를 숫자로 알려 준다. 세 축이 서로 수직인 단위벡터라면 3×3 행렬식은 (x축 × y축)·z축과 같고, +1이면 오른손, −1이면 왼손이다. 외적 a × b는 2×2 행렬식 세 개로 계산한다.
a × b = (a2·b3 − a3·b2, a3·b1 − a1·b3, a1·b2 − a2·b1)
올바른 매핑부터 손으로 계산해 보자. x_opt × y_opt = (0, −1, 0) × (0, 0, −1) = ((−1)(−1) − 0·0, 0·0 − 0·(−1), 0·0 − (−1)·0) = (1, 0, 0)이고, 이것과 z_opt = (1, 0, 0)의 내적은 1이다.
이제 틀린 매핑 두 개를 같은 방식으로 본다. 둘 다 설명을 위해 일부러 만든 대조군이다.
- 거울상 오류: optical x를 오른쪽 대신 왼쪽 (0, 1, 0)으로 잡았다. (0, 1, 0) × (0, 0, −1) = (−1, 0, 0)이므로 행렬식은 −1이다. 행렬식 검사가 이 오류를 잡는다.
- 180° 오류: optical x를 왼쪽 (0, 1, 0), y를 위 (0, 0, 1)로 잡았다. (0, 1, 0) × (0, 0, 1) = (1, 0, 0)이므로 행렬식은 +1이다. 행렬식 검사를 통과한다.
180° 오류는 x와 y를 둘 다 뒤집었기 때문에 손잡이가 다시 오른손으로 돌아왔다. 좌표로만 보면 올바른 optical 프레임을 광축(z)을 중심으로 180° 돌린 것과 같다. 오른손 법칙 비유의 한계가 여기서 드러난다. 손가락은 좌표계가 오른손인지 왼손인지만 알려 줄 뿐, 몸체의 어느 축이 optical의 어느 축에 대응해야 하는지는 알려 주지 않는다. 행렬식 검사의 한계도 정확히 같다. +1은 “회전이다”라는 필요조건이지 “REP-103과 같은 회전이다”라는 증거가 아니다.
거리 검사는 틀린 축 매핑을 하나도 거르지 못한다
변환 전후에 원점까지의 거리가 같은지 보는 검사도 흔히 떠올린다. 예제 점의 제곱 거리는 2² + 0.5² + 0.3² = 4.34 m²이고, 올바른 결과 (−0.5, −0.3, 2)도 4.34 m²다. 문제는 틀린 결과도 모두 4.34 m²라는 점이다. 거울상 오류의 (0.5, −0.3, 2)도, 180° 오류의 (0.5, 0.3, 2)도 같은 세 숫자의 순서와 부호만 바꾼 것이기 때문이다.
이 현상은 두 예시에 그치지 않는다. 각 축을 다른 축 하나에 +나 −로 대응시키는 매핑을 부호 붙은 축 치환(signed permutation)이라고 하는데, 3축에서는 축 순서 3! = 6가지와 부호 2³ = 8가지를 곱해 48개가 있다. 예제 점 (2, 0.5, 0.3)을 48개 모두에 넣어 산술 검산한 결과 제곱 거리는 전부 4.34 m²였다. 성분의 순서와 부호만 바뀌면 제곱의 합은 변하지 않으니, 거리 보존 검사는 축 매핑 오류를 하나도 구별하지 못한다.
행렬식은 적어도 절반을 거른다. 48개 중 행렬식이 +1인 매핑이 24개, −1인 매핑이 24개다. 정답은 +1 쪽에 있으므로, 행렬식 검사를 통과하는 오류가 23개 남는다. 앞의 180° 오류가 그중 하나다.
기대 좌표를 적어 둔 기준점이 규약 일치를 확인한다
남은 23개를 거르려면 “REP-103에서 손으로 유도한 답”을 테스트 안에 박아 두어야 한다. 기준점 테스트(reference point test)는 손으로 계산한 기대 좌표와 코드 출력을 비교하는 단위 테스트다. 예제 점 (2, 0.5, 0.3)의 기대 좌표 (−0.5, −0.3, 2)와 48개 매핑의 출력을 비교하면 일치하는 매핑은 정답 하나뿐이었다.
| 검사 | 정답 매핑 | 거울상 오류 (x = 왼쪽) | 180° 오류 (x = 왼쪽, y = 위) | 48개 중 걸러 내는 수 |
|---|---|---|---|---|
| 거리 보존 (제곱 거리 4.34) | 통과 | 통과 | 통과 | 0 |
| 행렬식 = +1 | 통과 (+1) | 실패 (−1) | 통과 (+1) | 24 (거울상만) |
| 기준점 (2, 0.5, 0.3) → (−0.5, −0.3, 2) | 통과 | 실패 (x가 +0.5) | 실패 (x 1.0 m, y 0.6 m 차이) | 47 (정답 외 전부) |
표를 읽는 방법은 이렇다. 거리 보존과 행렬식은 “이 변환이 회전인가”를 묻는다. 기준점 비교만이 “이 회전이 규약과 같은 회전인가”를 묻는다. 두 질문은 다르고, 부호 실수는 두 번째 질문에서 걸린다.
세 검사를 한 번에 돌려 보는 최소 코드는 다음과 같다. 표준 라이브러리만 쓴다. 이 블록을 check_optical.py로 저장한 뒤 python3 check_optical.py로 실행하면 된다. 작성 단계에서 2026년 9월 27일 Linux의 Python 3.12.3으로 같은 코드를 그대로 실행해 아래 출력과 종료 코드 0을 확인했다. 다른 Python 버전에서는 실행하지 않았다.
import math
# 행: optical 축을 몸체 좌표로 적은 단위벡터 (몸체 -> optical 좌표 변환)
R_BODY_TO_OPTICAL = (
(0, -1, 0), # optical x = 오른쪽 = -몸체 y
(0, 0, -1), # optical y = 아래 = -몸체 z
(1, 0, 0), # optical z = 앞 = +몸체 x
)
def apply(R, p):
return tuple(sum(r * v for r, v in zip(row, p)) for row in R)
def det3(R):
(a, b, c), (d, e, f), (g, h, i) = R
return a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)
def check_mapping(R):
p_body = (2.0, 0.5, 0.3) # 0이 아니고 절댓값이 서로 다른 기준점
expected = (-0.5, -0.3, 2.0) # REP-103에서 손으로 유도한 기대 좌표
got = apply(R, p_body)
same = all(math.isclose(g, e, rel_tol=1e-9, abs_tol=1e-9)
for g, e in zip(got, expected))
return det3(R), got, same
MIRROR = ((0, 1, 0), (0, 0, -1), (1, 0, 0)) # x = 왼쪽
ROT180 = ((0, 1, 0), (0, 0, 1), (1, 0, 0)) # x = 왼쪽, y = 위
for name, R in [("정답", R_BODY_TO_OPTICAL), ("거울상", MIRROR), ("180도", ROT180)]:
d, got, same = check_mapping(R)
print(f"{name}: det={d:+d} got={got} match={same}")
정답: det=+1 got=(-0.5, -0.3, 2.0) match=True
거울상: det=-1 got=(0.5, -0.3, 2.0) match=False
180도: det=+1 got=(0.5, 0.3, 2.0) match=False
정상 판정은 첫 줄처럼 det=+1과 match=True가 함께 나오는 경우다. 셋째 줄이 이 글의 요점이다. 행렬식은 +1인데 기준점이 어긋나므로, 행렬식만 확인하는 테스트였다면 이 매핑은 통과했을 것이다. 부동소수 비교에는 math.isclose로 허용 오차를 두었다. 0.1 같은 값은 이진수로 정확히 표현되지 않아 == 비교가 우연히 실패할 수 있기 때문이다.
이 글의 산술은 후보 단계에서 따로 만든 교육용 예제와도 대조했다. 예제 코드 example.py는 같은 매핑과 기준점을 8개 값으로 검산하고, 검증 기록 verification.json에는 2026년 9월 27일 Python 3.12.3에서 python3 -I example.py를 실행해 종료 코드 0을 얻은 출력이 남아 있다. 조사 단계에서 이 예제를 다시 실행해 8개 값이 기록과 같음을 확인했고, 48개 전수 비교와 아래 반례는 별도 스크립트로 검산했다. 모두 산술 검산이며 로봇이나 물리 엔진을 돌린 실험이 아니다.
기준점은 아무 점이나 되지 않는다
기준점은 세 성분이 모두 0이 아니고, 절댓값이 서로 달라야 한다. 부호는 상관없다. 예제 점 (2, 0.5, 0.3)처럼 세 성분이 모두 양수여도 된다. 이유는 두 가지다.
- 성분이 0이면 그 축의 부호를 뒤집어도 결과가 같다. −0은 0이기 때문이다.
- 절댓값이 같은 두 성분이 있으면 두 축을 서로 바꿔도 결과가 같을 수 있다.
반례를 48개 전수 비교로 확인한 결과는 다음과 같다. 개수는 기대 좌표와 같은 결과를 내는 매핑 수이며 정답을 포함한다.
- (2, 0, 0): 8개. 앞의 180° 오류도 여기에 섞여 통과한다. 앞쪽 한 점만 찍는 테스트가 위험한 이유다.
- (1, 1, 1): 6개.
- (2, 0.5, 0.5): 2개.
- (2, 0.5, 0.3): 1개. 정답만 남는다.
“정면 2 m 앞 한 점”은 직관적인 테스트 입력이지만, 좌우와 상하 성분이 0이라서 부호 실수를 거의 보지 못한다.
확인 문제
문제 1. 동료가 몸체→optical 행렬을 ((0, 1, 0), (0, 0, 1), (1, 0, 0))로 짰다. 행렬식을 계산해 보니 +1이고, 변환 전후 거리도 같았다. 이 매핑을 받아들여도 될까?
답: 받아들이면 안 된다. 기준점 (2, 0.5, 0.3)을 넣으면 (0.5, 0.3, 2)가 나오지만 기대 좌표는 (−0.5, −0.3, 2)다. optical x와 y의 부호가 모두 반대인 180° 오류다. 행렬식과 거리는 회전이라는 사실만 확인했을 뿐이다.
문제 2. 테스트 입력으로 몸체 점 (2, 0, 0)을 골랐고, 기대 좌표 (0, 0, 2)와 출력이 같았다. 이 테스트로 문제 1의 오류를 잡을 수 있을까?
답: 잡을 수 없다. (2, 0, 0)에서는 y와 z 성분이 0이라 부호를 뒤집어도 결과가 (0, 0, 2)로 같다. 48개 중 8개 매핑이 이 테스트를 통과하고, 180° 오류도 그 안에 있다.
문제 3. optical 좌표 (−0.5, −0.3, 2)를 몸체 좌표로 되돌리려고 행 쌓기 행렬을 한 번 더 곱했다. 맞는 방법일까?
답: 아니다. 되돌릴 때는 열 쌓기 행렬(전치 행렬)을 써야 하고, 그 결과는 (2, 0.5, 0.3)이다. 두 행렬 모두 행렬식이 +1이라 행렬식으로는 방향 실수를 구별할 수 없다. 역방향에도 기대 좌표를 적은 기준점 테스트를 따로 둔다.
카메라가 비스듬히 달렸다면 점 하나로는 부족하다
기준점 하나로 오류를 모두 가를 수 있다는 결론은 축끼리 90° 단위로만 대응하는 매핑에 한정된다. 카메라를 아래로 기울여 다는 것처럼 일반 회전이 섞이면 사정이 달라진다. 원점과 기준점 p를 잇는 직선을 축으로 도는 회전은 p를 제자리에 두기 때문이다. 산술 검산에서 p 방향을 축으로 0.3 rad 돈 회전을 정답에 곱한 틀린 행렬을 만들었더니, 행렬식은 +1이고 p의 결과도 정답과 같았다. 두 번째 점 (0, 0, 1) m를 넣었을 때 비로소 성분별로 (0.2822, 0.0437, 0.0771) m만큼 어긋났다.
두 점이면 충분하냐고 하면 그것도 아니다. p와 (0, 0, 1)이 이루는 평면에 대한 반사는 행렬식이 −1이면서 두 점을 모두 그대로 둔다. 그래서 일반 회전을 다루는 변환 코드에는 “두 점 비교 + 행렬식 +1 확인”을 함께 두거나, 원점과 함께 한 평면에 놓이지 않는 세 점을 비교해야 한다.
다루지 않은 것도 분명히 해 둔다. 카메라가 몸체 원점에서 떨어져 달린 경우의 이동량(translation), 렌즈 내부 파라미터 K, 화소를 3D 점으로 되돌리는 역투영, 깊이 오차는 이 예제의 범위 밖이다. static_transform_publisher 인자나 쿼터니언, launch 파일은 실행해 보지 않았으므로 복사용 명령으로 싣지 않았다. 실제 로봇의 camera_link에서 _optical 프레임으로 이어지는 변환 트리와 드라이버 출력은 해당 드라이버와 tf2 문서를 보고 자신의 실행 환경에서 확인해야 한다. 이 계산만 믿고 카메라 인식 결과를 로봇의 제어 명령에 연결해서도 안 된다. 축 부호 하나가 틀리면 물체가 왼쪽이 아니라 오른쪽에 있다고 판단하게 된다.
직접 짠 몸체→optical 변환 함수가 있다면 두 가지를 테스트에 추가하는 것부터 시작하면 된다. 행렬식이 +1인지 확인하는 한 줄과, (2, 0.5, 0.3)이 (−0.5, −0.3, 2)로 가는지 허용 오차를 두고 비교하는 한 줄이다. 장착 각도가 90° 단위가 아니라면 기준점을 두 개 이상으로 늘린다. 거리 보존 검사는 축 매핑을 검증하는 수단으로 쓰지 않는다.
2D 좌표 변환부터 다시 정리하고 싶다면 피지컬 AI 좌표계 기초 글을, 프레임 이름과 변환 트리를 누가 관리하는지 궁금하다면 ROS 2와 tf2의 역할을 구분한 글을 이어서 읽으면 된다. 같은 REP-103의 다른 축 규약과 테스트 입력 고르는 법은 나침반 방위를 ROS yaw로 바꾸는 글에서도 다룬다.
참고 링크
- REP-103: Standard Units of Measure and Coordinate Conventions (master 원문) — 오른손 좌표계, 몸체 축,
_optical접미사 프레임 규약. 2026-09-27 확인, 고정 커밋 6fbda48과 내용 동일. - ROS 2 common_interfaces
sensor_msgs/msg/CameraInfo.msg(고정 커밋 27b84d3) — optical frame의 원점과 +x·+y·+z 방향. 2026-09-27 확인. - HuntLab 교육용 예제 example.py와 검증 기록 verification.json — 산술 검산 원본과 실행 기록.
이 글에는 제휴·협찬이 없다.