Niyarin's blog

単純な2D point-to-point ICP アルゴリズム

Tags:Robotics
2026-09-05


この記事では、単純な2Dのpoint-to-point Iterative Closest Point (ICP)の実装や式の導出について説明します。

point-to-point ICP 1は、2つの点群間の移動距離と回転を求める手法です。 LiDARを積んだロボットに対してICPすることで、ロボットの姿勢を指定することができるようになります。

2つの点群から姿勢を推定する問題

ICPで解きたい問題は、2つの点群間の移動距離と回転の量です。

この図は、点群sourceとそれを(10, 5)分移動し、60度回転したtargetという点群を示しています。

2つの点群の例

ICPは、このsourceとtargetを入力にとり、移動量である(10, 5)と回転量の60度を出力します。

アルゴリズム

単純には、点群pがqに限りなく同じになるまで回転と平行移動の推論と移動を繰り返します。 複数回繰り返す理由は、点群の対応関係を正確に出すことができないからです。

1回のイテレーションは次のような手順で行います。 Orthogonal Procrustes problem 2 3を解きます。

  • 1. 両方の点群の対応関係を求め、対応関係にある点のみ今回のイテレーションの計算に使う
  • 2. 両方の点群の重心を求める
  • 3. 重心が原点にくるように2つの点群を移動する
  • 4. 回転を推定する
  • 5. 移動量を推定する
  • 6. 最初の点に回転と移動を反映する

最後に移動量と回転量の各総和が、2つの点群の移動量と回転量です。

点群の対応関係を求める

片側の点群のある点がもう片方の点群のどの点と対応するか求めます。 これは単純に距離(ユークリッド距離)が一番近いものを対応する点とします。

なお、点pkと点pk+1のどちらも点qlに対応することは問題ありません。

点群の重心を求める

単純に点群の重心は、単に平均座標です。

px=1NiNpixpy=1NiNpiy

回転を推定する

2Dの場合は、下の数式のセクションで導出しますが、点の内積和、外積和、atan2で求めます。

def estimate_angle(points, matched, centroid_points , centroid_matched):
    px,py = centroid_points
    qx,qy = centroid_matched

    dot = 0.0
    cross = 0.0
    for p, q in zip(points, matched):
        ax, ay = p[0] - px, p[1] - py
        bx, by = q[0] - qx, q[1] - qy
        dot += ax * bx + ay * by
        cross += ax * by - ay * bx

    return math.atan2(cross, dot)

移動の推定

求めた回転で点群1つめのフレームの重心を回転させたものと 2つめのフレームの重心の差ベクトルが移動量になります。

実装

import math
from typing import Sequence, Tuple

Point = Tuple[float, float]
Pose = Tuple[float, float, float]

def calc_centroid(points):
    return (sum(p[0] for p in points) / len(points),
            sum(p[1] for p in points) / len(points))

def estimate_angle(points, matched, centroid_points , centroid_matched):
    px,py = centroid_points
    qx,qy = centroid_matched

    dot = 0.0
    cross = 0.0
    for p, q in zip(points, matched):
        ax, ay = p[0] - px, p[1] - py
        bx, by = q[0] - qx, q[1] - qy
        dot += ax * bx + ay * by
        cross += ax * by - ay * bx

    return math.atan2(cross, dot)

def estimate_transform(points, matched):
    px,py = calc_centroid(points)
    qx,qy = calc_centroid(matched)

    angle = estimate_angle(points, matched, (px,py), (qx,qy))

    # calc translaction
    c, s = math.cos(angle), math.sin(angle)
    tx = qx - (c * px - s * py)
    ty = qy - (s * px + c * py)
    return tx,  ty, angle

def point_to_point_icp(
    source: Sequence[Point],
    target: Sequence[Point],
    max_iterations: int = 50,
    tolerance: float = 1e-6,
) -> Pose:
    points = [(float(x), float(y)) for x, y in source]
    targets = [(float(x), float(y)) for x, y in target]

    total_angle = 0.0
    total_tx = 0.0
    total_ty = 0.0

    for _ in range(max_iterations):
        matched = [
            min(targets, key=lambda q: (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2)
            for p in points
        ]

        tx, ty, angle = estimate_transform(points, matched)

        c, s = math.cos(angle), math.sin(angle)
        # new points
        points = [(c * x - s * y + tx, s * x + c * y + ty) for x, y in points]

        # calc sum of the amount of translation
        total_tx, total_ty = (
            c * total_tx - s * total_ty + tx,
            s * total_tx + c * total_ty + ty,
        )
        total_angle = math.atan2(
            math.sin(total_angle + angle), math.cos(total_angle + angle)
        )

        if math.hypot(tx, ty) < tolerance and abs(angle) < tolerance:
            break

    return total_tx, total_ty, total_angle

推論の途中経過の観察

冒頭の例で示した点群を実際にこのプログラムで推論し、その途中状態を表示してみました。 グレーの点群が移動後の点群で、色つきのものは各イテレーションで推論した移動を反映したもとの点群です。

iter20ほどで推論がほぼ収束してそうです。

推論の途中経過の観察

角度θを出す式の導出

それぞれ、原点に重心が来るように移動した点群をpi , qiとします。

目的関数は次のようになります。 ここで、RR=Iを使います。

E(R(θ))=iqi-R(θ)pi2=i(qi2+pi2-2qiR(θ)pi)

R(θ)に関係ない部分を除外すると、次のようなargmin問題になります。

argmini-2qiR(θ)pi

さらにargminを解くのに係数も不要で、符号を逆転しargmaxにします。

argmaxiqiR(θ)pi

2Dの回転は1変数なので、微分で角度θをだすことができます。

SVDでθを出す版も書いておきます。

3Dは回転は3軸のため微分ではなく特異値分解(SVD)等で求める必要があります。

微分で角度θを出す

1軸の回転行列は、

R(θ)=(cos(θ)-sin(θ)sin(θ)cos(θ))

piqiは、

pi=(pixpiy)qi=(qixqiy)

とします。

qiR(θ)piを展開すると、

qiR(θ)pi=(qixpix+qiypiy)cos(θ)+(qiypix-qixpiy)sin(θ)

ここで、全ての点についてまとめ、

A=iqixpix+qiypiyB=iqiypix-qixpiy

とすると、

argmaxf(θ)=Acos(θ)+Bsin(θ)

を解く問題となります。

微分すると、

dfdθ=-Asin(θ)+Bcos(θ)

なので、

-Asin(θ)+Bcos(θ)=0tan(θ)=BAθ=atan2(B,A)=atan2(iqiypix-qixpiy,iqixpix+qiypiy)

(SVD) 角度θを出す

qiR(θ)piはスカラーなので、trしてもおなじです。

itr(qiR(θ)pi)

trの巡回性を使って変形する。

itr(qiR(θ)pi)=tr(R(θ)ipiqi)

ここで、 H=ipiqi とすると、 tr(R(θ)H)

さらに、HをSVDし、

H=UΣV

さらに、巡回性を利用し

tr(R(θ)H)=tr(R(θ)UΣV)=tr(VR(θ)UΣ)

Σは、対角成分が非負で他が0なので、最大化にはVR(θ)Uの対角成分以外は0でよい。 また、VR(θ)Uは直交行列なので、各要素は1を超えない。

よって、

VR(θ)U=I

これを変形すると、

R(θ)=VU

これに代入して、θを出します。

R(θ)=(cos(θ)-sin(θ)sin(θ)cos(θ))

References