어제는 벡터가 깨지는 자리, 오늘은 그 벡터를 돌리는 법
어제 정규화·정사영·법선이 랭크가 떨어지는 자리에서 전부 nan이 된다고 정리했다. 오늘은 그 벡터를 실제로 돌리는 도구인 회전 행렬을 극좌표에서부터 유도했다.
그런데 노트에 3차원 확장을 적으면서 y축 행렬의 부호를 틀리게 썼다. 그리고 이 틀린 행렬은 어떤 검사에도 안 걸린다. 어제 nan은 그래도 nan이라고 티라도 냈는데, 오늘 것은 완벽하게 정상인 얼굴을 하고 반대로 돈다. 오늘 제일 크게 배운 게 이거다.
2D 회전 — 극좌표에 덧셈정리를 씌우면 r이 사라진다
점 하나를 극좌표로 놓고 시작한다.
x = r cos θ, y = r sin θ
여기서 θ′만큼 더 돌린 점은
x′ = r cos(θ + θ′), y′ = r sin(θ + θ′)
삼각함수 덧셈정리를 펴면
cos(θ + θ′) = cos θ cos θ′ − sin θ sin θ′
sin(θ + θ′) = cos θ sin θ′ + sin θ cos θ′
양변에 r을 곱하면 r cos θ와 r sin θ가 통째로 원래 x, y로 돌아온다. r과 θ가 식에서 사라지는 게 핵심이다.
∴ x′ = x cos θ′ − y sin θ′, y′ = x sin θ′ + y cos θ′
[ x′ ] [ cos θ −sin θ ] [ x ] [ cos θ −sin θ ]
[ y′ ] = [ sin θ cos θ ] [ y ] ⟹ R(θ) = [ sin θ cos θ ]
출발은 극좌표였는데 결과에는 각도가 하나뿐이다. 그래서 점이 원점에서 얼마나 떨어져 있든 같은 행렬 하나로 돌릴 수 있다. 회전이 선형 변환이라는 게 이 유도의 결론이다.
열은 회전된 기저축이다 — 덧셈정리 없이 같은 답이 나온다
오늘 관점이 바뀐 지점이 여기다. 행렬 곱의 정의상 R @ e_x는 R의 1열이고, R @ e_y는 2열이다. 그러니까 회전 행렬의 각 열은 **“원래 축이 회전 후에 어디로 갔나”**를 그대로 적어둔 것이다.
import numpy as np
t = np.deg2rad(30)
Rz = np.array([[np.cos(t), -np.sin(t), 0],
[np.sin(t), np.cos(t), 0],
[0, 0, 1]])
Rz @ np.array([1., 0., 0.]) # [0.866, 0.5, 0.] ← 정확히 1열
그러면 유도가 필요 없어진다. 단위원에서 x̂ = (1, 0)을 θ 돌리면 (cos θ, sin θ), ŷ = (0, 1)을 θ 돌리면 (−sin θ, cos θ). 두 개를 세로로 세워 붙이면 그게 R(θ)다.
덧셈정리 유도가 틀렸다는 게 아니라, 부호를 검산할 방법이 하나 더 생겼다는 게 중요하다. 바로 다음 절에서 이게 필요해진다.
3차원 — 축 하나를 고정하고 남은 평면에서 2D 회전
3차원 회전은 축 하나를 고정하고 나머지 두 축이 만드는 평면에서 2D 회전을 하는 것이다. 그래서 고정축 자리에 1을 박고 나머지 네 칸에 2D 회전을 끼워 넣으면 된다. x축과 z축은 이 방식이 그대로 통한다.
[ 1 0 0 ] [ cos θ −sin θ 0 ]
Rx(θ) = [ 0 cos θ −sin θ ] Rz(θ) = [ sin θ cos θ 0 ]
[ 0 sin θ cos θ ] [ 0 0 1 ]
문제는 y축이다.
⚠️ y축 회전만 부호가 반대인 이유 (Why · How · What)
Why — 무엇이 문제였나 세 축이 전부 같은 패턴을 따를 거라고 보고 노트에
R_y를 이렇게 적었다.[ cos 0 −sin ] ← 내가 적은 것 (틀림) [ 0 1 0 ] [ sin 0 cos ]“y를 고정하고 남은 건 x랑 z니까 (x, z) 자리에 2D 회전을 그대로 넣으면 되겠지"라는 생각이었다. 정답은 부호가 반대다.
[ cos 0 sin ] ← 표준 (오른손 좌표계) [ 0 1 0 ] [ −sin 0 cos ]How — 어떻게 확인했나 틀린 이유는 “남은 두 축"의 순서다. 축 회전의 방향은 오른손 법칙이 정하고, 오른손 좌표계에서 각 축 둘레의 회전은 순환 순서 x → y → z → x를 따른다. 그러니까 y축 회전이 도는 평면은 (x, z)가 아니라 **(z, x)**다 — y 다음이 z, 그다음이 x니까.
회전축 2D 회전이 일어나는 평면 −sin이 앉는 자리x (y, z) 위 y (z, x) — 뒤집힌 게 아니라 순환을 따른 것 아래 z (x, y) 위 앞 절의 “열 = 회전된 기저축"으로 검산하면 외울 필요도 없다. y축을 오른손 엄지로 잡고 30° 돌리면 x축은 z가 커지는 쪽이 아니라 −z 쪽으로 내려간다.
Ry30 @ np.array([1., 0., 0.]) # array([ 0.866, 0. , -0.5 ]) ← z 성분이 음수. 표준이 맞다What — 무엇을 배웠나 진짜 배운 건 부호 자체가 아니라 틀린 행렬이 아무 검사에도 안 걸린다는 것이다. 내가 적은 행렬을 그대로 돌려봤다.
np.linalg.det(Ry_wrong) # 1.0 ← 정상 np.allclose(Ry_wrong.T @ Ry_wrong, np.eye(3)) # True ← 정상 np.allclose(Ry_wrong(t), Ry(-t)) # True np.allclose(Ry_wrong(t), Ry(t).T) # True
det = 1,RᵀR = I둘 다 통과한다. 틀린 게 아니라−θ회전, 즉 역회전이다. 반사도 아니고 스케일도 안 섞였으니 회전 행렬 검사로는 절대 못 잡는다. 어제nan은 그래도 화면에nan이라고 찍혔는데, 이건 로봇이 반대로 도는 걸 눈으로 보기 전까지 모른다.그래서 부호 검산은 성질이 아니라 동작으로 해야 한다. 단위 벡터 하나를 넣어서 어디로 가는지 보는 것, 그게 전부다.
회전은 순서를 탄다
2차원에서는 회전을 아무 순서로 해도 결과가 같다. 3차원부터는 아니다. 위키피디아도 n > 2부터 회전 행렬 곱이 일반적으로 교환되지 않는다고 못 박아 뒀다.
v = np.array([0., 0., 1.]) # z축 단위 벡터
Ry90 @ Rx90 @ v # [ 0., -1., 0.]
Rx90 @ Ry90 @ v # [ 1., 0., 0.]
# 두 결과 사이 각도: 90.0°
같은 회전 두 개를 순서만 바꿨는데 결과가 90° 어긋난다. 그래서 3차원 자세를 오일러 각으로 말할 때 “ZYX 순서"처럼 순서를 반드시 같이 적어야 한다. 각도 세 개만 주고받으면 그건 자세를 안 준 것이다.
회전 행렬은 회전만 하지, 아무것도 안 잃는다
어제 표의 결론이 “랭크가 떨어지면 무너진다"였는데, 회전 행렬은 그 반대편에 있다.
| 성질 | 의미 |
|---|---|
Rᵀ = R⁻¹ | 역회전이 전치다. 역행렬을 계산할 필요가 없다 |
det R = +1 | 부피도 방향도 보존. det = −1이면 반사가 섞인 것 |
| 열이 정규직교 | 랭크가 항상 3. 절대 떨어지지 않는다 |
|Rv| = |v| | 길이 보존. 정규화한 벡터는 회전 후에도 단위 벡터 |
이 넷을 만족하는 행렬들의 모임이 **SO(3)**다.
실전에서 제일 큰 건 Rᵀ = R⁻¹이다. 역변환이 필요할 때 np.linalg.inv를 부르지 않고 .T만 붙이면 되니 공짜인 데다, 역행렬 계산에서 생기는 수치 오차도 아예 없다.
그리고 어제와 이어지는 지점 하나. 회전 행렬은 랭크가 안 떨어지니 회전 자체는 nan을 만들지 않는다. 대신 nan 벡터를 넣으면 그대로 통과시켜 전 성분에 퍼뜨린다. 어제 “nan 벡터를 회전에 넣으면 자세 전체가 nan이 된다"고 적어둔 게 이 얘기였다. 막을 자리는 회전이 아니라 그 앞의 정규화다.
그런데 계속 곱하면 회전 행렬이 아니게 된다
성질이 아무리 깔끔해도 부동소수점에서 반복 곱셈을 하면 조금씩 어긋난다. 작은 회전을 계속 누적시켜 봤다.
| 곱한 횟수 | |RᵀR − I| 최대 | det R |
|---|---|---|
| 10 | 1.8e-15 | 1.000000000000000 |
| 1,000 | 5.6e-14 | 0.999999999999960 |
| 100,000 | 6.1e-12 | 0.999999999995898 |
| 1,000,000 | 6.1e-11 | 0.999999999958955 |
오차가 곱한 횟수에 거의 비례해서 자란다. 100만 번이면 det가 1에서 눈에 보이게 내려온다. 이 상태의 행렬은 순수한 회전이 아니라 아주 약한 스케일과 전단이 섞인 변환이고, 매 프레임 자세를 누적하는 시뮬레이션이나 IMU 적분에서 물체가 서서히 찌그러지는 이유가 이거다.
고치는 방법은 **가장 가까운 회전 행렬로 다시 끌어당기는 것(재직교화)**이다. SVD를 쓰면 두 줄이다.
U, S, Vt = np.linalg.svd(R_bad)
R_fixed = U @ Vt # 특이값을 전부 1로 만든 것과 같다
일부러 1e-3 크기의 잡음을 섞은 행렬로 확인했다.
망가진 R : 직교성 오차 2.21e-03, det 0.998897
SVD 복원 : 직교성 오차 6.66e-16, det 1.000000 (원본과의 차이 4.8e-04)
U @ Vt는 특이값을 전부 1로 갈아끼우는 것과 같고, 그게 프로베니우스 노름 기준으로 원래 행렬에 가장 가까운 직교행렬이다. 어제 정사영에서 정리한 그람-슈미트도 같은 일을 더 싸게 하는 방법인데, 축 순서에 따라 결과가 달라지는 대신(1열은 그대로 두고 2열부터 깎으니까) 훨씬 빠르다. 정확도가 중요하면 SVD, 매 프레임 돌려야 하면 그람-슈미트다.
쓰면 좋은 것
import numpy as np
def Rx(t):
c, s = np.cos(t), np.sin(t)
return np.array([[1, 0, 0], [0, c, -s], [0, s, c]])
def Ry(t):
c, s = np.cos(t), np.sin(t)
return np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]]) # y만 부호가 반대
def Rz(t):
c, s = np.cos(t), np.sin(t)
return np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]])
def inv_rot(R):
"""회전의 역은 전치다. inv()를 부르지 않는다."""
return R.T
def is_rotation(R, tol=1e-8):
"""직교성과 det만 본다 — 부호 방향까지는 못 잡는다는 걸 기억할 것."""
return (np.abs(R.T @ R - np.eye(3)).max() < tol
and abs(np.linalg.det(R) - 1.0) < tol)
def reorthogonalize(R):
"""누적 오차로 어긋난 행렬을 가장 가까운 회전 행렬로 되돌린다."""
U, _, Vt = np.linalg.svd(R)
return U @ Vt
def check_axis_sign(R, axis=0):
"""부호 검산은 성질이 아니라 동작으로. 축이 어디로 가는지 직접 본다."""
e = np.zeros(3); e[axis] = 1.0
return R @ e
정리
- 극좌표에 덧셈정리를 씌우면
r과 원래 각θ가 사라지고 각도 하나짜리 행렬만 남는다. 그래서 원점에서 얼마나 떨어져 있든 같은 행렬로 돌릴 수 있다 — 회전이 선형 변환인 이유다. - 회전 행렬의 열은 회전된 기저축이다.
R @ e_x가 곧 1열이라, 유도를 안 해도 축이 어디로 가는지만 그리면 행렬이 나온다. - 3차원 축 회전은 축 하나를 고정하고 남은 평면에서 하는 2D 회전인데, **y축만 그 평면이 (x, z)가 아니라 (z, x)**다. 순환 순서 x → y → z → x를 따르기 때문이고, 그래서
−sin이 반대쪽에 앉는다. - 부호를 틀려도
det = 1,RᵀR = I를 그대로 만족한다. 틀린 게 아니라−θ회전이라서 그렇다. 회전 행렬 검사로는 절대 안 걸리니 단위 벡터를 넣어 어디로 가는지 보는 게 유일한 검산이다. - 3차원부터 회전은 교환되지 않는다. 같은 회전 두 개를 순서만 바꿨더니 결과가 90° 어긋났다. 오일러 각은 각도 세 개가 아니라 각도 세 개 + 순서여야 자세가 된다.
Rᵀ = R⁻¹이라 역회전이 공짜다. 열이 정규직교라 랭크가 절대 안 떨어지고, 그래서 회전 자체는 어제의nan함정을 만들지 않는다. 대신 들어온nan은 전 성분에 퍼뜨린다 — 막을 자리는 회전 앞의 정규화다.- 반복해서 곱하면 직교성이 곱한 횟수에 비례해 무너진다. 100만 번에
det가0.99999999996까지 내려왔다. 누적 자세를 굴리는 코드는 주기적으로 재직교화해야 하고, SVD의U @ Vt가 가장 가까운 회전 행렬을 준다.