【Windows11】Python + Open3D で点群データの合成1

Python
スポンサーリンク

スポンサーリンク

はじめに

前回の記事では、Open3D と Blender で点群データの表示を行いました。

今回の記事では、二つの点群データを合成する方法について説明します。

前提条件

前提条件は以下の通りです。

  • pyrealsense2
  • OpenCV ==4.6.0
  • Python == 3.9.13
  • Windows11
  • open3D = 0.16.0

事前準備

事前準備として、Realsense で二つの角度から撮影した点群データを用意します。

正面から撮影したファイルを “output-front.ply”
斜めから撮影したファイルを “output-side.ply”
として保存します。

二つの点群データを一つの window 上で合成せずに表示すると以下のようになります。

ずれていますね。今回はこれを合成していきます。

点群データ合成プログラム

早速、合成プログラムを以下に記載します。
こちらのプログラムは、Open3D の 公式example のコードを少し変更したものです。

import open3d as o3d
import numpy as np

# load pcd data
def load_point_clouds(voxel_size=0.0, pcd_data=""):
    pcds = []
    for i in range(len(pcd_data)):
        pcd = o3d.io.read_point_cloud(pcd_data[i])
        pcd.paint_uniform_color([1-i, 0.706, i])
        pcd_down = pcd.voxel_down_sample(voxel_size=voxel_size)
        pcds.append(pcd_down)
    return pcds


def pairwise_registration(source, target, max_correspondence_distance_coarse,
                          max_correspondence_distance_fine):
    print("Apply point-to-plane ICP")
    icp_coarse = o3d.pipelines.registration.registration_icp(
        source, target, max_correspondence_distance_coarse, np.identity(4),
        o3d.pipelines.registration.TransformationEstimationPointToPlane())
    icp_fine = o3d.pipelines.registration.registration_icp(
        source, target, max_correspondence_distance_fine,
        icp_coarse.transformation,
        o3d.pipelines.registration.TransformationEstimationPointToPlane())
    transformation_icp = icp_fine.transformation
    information_icp = o3d.pipelines.registration.get_information_matrix_from_point_clouds(
        source, target, max_correspondence_distance_fine,
        icp_fine.transformation)
    return transformation_icp, information_icp


def full_registration(pcds, max_correspondence_distance_coarse, max_correspondence_distance_fine):
    pose_graph = o3d.pipelines.registration.PoseGraph()
    odometry = np.identity(4)
    pose_graph.nodes.append(o3d.pipelines.registration.PoseGraphNode(odometry))
    n_pcds = len(pcds)
    for source_id in range(n_pcds):
        for target_id in range(source_id + 1, n_pcds):
            transformation_icp, information_icp = pairwise_registration(
                pcds[source_id], pcds[target_id],
                max_correspondence_distance_coarse,
                max_correspondence_distance_fine)
            print("Build o3d.pipelines.registration.PoseGraph")
            if target_id == source_id + 1:  # odometry case
                print("odometry case")
                odometry = np.dot(transformation_icp, odometry)
                pose_graph.nodes.append(o3d.pipelines.registration.PoseGraphNode(np.linalg.inv(odometry)))
                pose_graph.edges.append(o3d.pipelines.registration.PoseGraphEdge(source_id,
                    target_id, transformation_icp, information_icp, uncertain=False))

            else:  # loop closure case
                print("loop closure")
                pose_graph.edges.append(o3d.pipelines.registration.PoseGraphEdge(source_id,
                    target_id, transformation_icp, information_icp, uncertain=True))
    return pose_graph


if __name__ == "__main__":
    voxel_size = 0.005
    pcd_data = ["output-front.ply", "output-side.ply"]
    pcds_down = load_point_clouds(voxel_size, pcd_data)
    o3d.visualization.draw_geometries(pcds_down, "vovel sampling")

    print("Full registration ...")
    max_correspondence_distance_coarse = voxel_size * 15
    max_correspondence_distance_fine = voxel_size * 1.5
    # debug full registration
    with o3d.utility.VerbosityContextManager(o3d.utility.VerbosityLevel.Debug) as cm:
        pose_graph = full_registration(pcds_down,
                                        max_correspondence_distance_coarse,
                                        max_correspondence_distance_fine)

    print("Optimizing PoseGraph ...")
    option = o3d.pipelines.registration.GlobalOptimizationOption(
        max_correspondence_distance=max_correspondence_distance_fine,
        edge_prune_threshold=0.25,
        reference_node=0)
    # debug optimizing posegraph
    with o3d.utility.VerbosityContextManager(o3d.utility.VerbosityLevel.Debug) as cm:
        o3d.pipelines.registration.global_optimization(
            pose_graph,
            o3d.pipelines.registration.GlobalOptimizationLevenbergMarquardt(),
            o3d.pipelines.registration.GlobalOptimizationConvergenceCriteria(),
            option)

    print("Transform points and display")
    for point_id in range(len(pcds_down)):
        print(pose_graph.nodes[point_id].pose)
        pcds_down[point_id].transform(pose_graph.nodes[point_id].pose)
    o3d.visualization.draw_geometries(pcds_down)

非常に難解でした。理解できていない部分がありますので、説明しながら理解していきます。

早速、実行します。

最初の window の表示はこのようになります。

黄色と水色はそれぞれ角度の異なる点群データです。
window を閉じて、1s 程度経過すると合成された結果が表示されます。

完全に合成されてはいないですが、角度変換は合っているので、続けていきます。

プログラム説明

元データの読込と表示

はじめに、メイン関数から説明していきます。サブ関数が現れた時点で、サブ関数の説明に移ります。

voxel_size = 0.005
pcd_data = ["output-front.ply", "output-side.ply"]
pcds_down = load_point_clouds(voxel_size, pcd_data)
o3d.visualization.draw_geometries(pcds_down, "vovel sampling")

voxel_size と draw_geometries による複数の点群データの表示方法については、前回説明したので割愛します。

load_point_clouds

load_point_clouds 関数について説明していきます。

# load pcd data
def load_point_clouds(voxel_size=0.0, pcd_data=""):
    pcds = []
    for i in range(len(pcd_data)):
        pcd = o3d.io.read_point_cloud(pcd_data[i])
        pcd.paint_uniform_color([1-i, 0.706, i])
        pcd_down = pcd.voxel_down_sample(voxel_size=voxel_size)
        pcds.append(pcd_down)
    return pcds

o3d.io.read_point_cloud() で、.ply データを読み込みます。

paint_uniform_color([1-i, 0.706, i]) で、読み込んだ点群に色を付けます。
paint_uniform_color() の説明は公式ドキュメントの中腹にあります。
リスト内は RGB 表記で、全て 0 – 1 で記述します。

vocel_down_sample で、ダウンサンプリングを実行します。

複数の点群リストを pcds としてまとめます。

Pose Graph の作成

print("Full registration ...")
max_correspondence_distance_coarse = voxel_size * 15
max_correspondence_distance_fine = voxel_size * 1.5
# debug full registration
with o3d.utility.VerbosityContextManager(o3d.utility.VerbosityLevel.Debug) as cm:
    pose_graph = full_registration(pcds_down,
                                    max_correspondence_distance_coarse,
                                    max_correspondence_distance_fine)
  • max_correspondence_distance_coarse … 対応するボクセル間を切り離す最大距離
                         初期スクリーニングで相関有とみなす距離?
  • max_correspondence_distance_fine … 対応するボクセル間の相関が強いとする最大距離

o3d.utility.VerbosityContextManager(o3d.utility.VerbosityLevel.Debug) as cm
は、with 内の Open3D の挙動に関して、Debugログ を表示する関数です。

pose_graph = full_registration(pcds_down,
                                max_correspondence_distance_coarse,
                                max_correspondence_distance_fine)

full_registration について説明していきます。

full_registration

full_registration では、各点群データをまとめて Pose Graph 形式に変換します。

def full_registration(pcds, max_correspondence_distance_coarse, max_correspondence_distance_fine):
    pose_graph = o3d.pipelines.registration.PoseGraph()
    odometry = np.identity(4)
    pose_graph.nodes.append(o3d.pipelines.registration.PoseGraphNode(odometry))
    n_pcds = len(pcds)
    for source_id in range(n_pcds):
        for target_id in range(source_id + 1, n_pcds):
            transformation_icp, information_icp = pairwise_registration(
                pcds[source_id], pcds[target_id],
                max_correspondence_distance_coarse,
                max_correspondence_distance_fine)
            print("Build o3d.pipelines.registration.PoseGraph")
            if target_id == source_id + 1:  # odometry case
                print("odometry case")
                odometry = np.dot(transformation_icp, odometry)
                pose_graph.nodes.append(o3d.pipelines.registration.PoseGraphNode(np.linalg.inv(odometry)))
                pose_graph.edges.append(o3d.pipelines.registration.PoseGraphEdge(source_id,
                    target_id, transformation_icp, information_icp, uncertain=False))

            else:  # loop closure case
                print("loop closure")
                pose_graph.edges.append(o3d.pipelines.registration.PoseGraphEdge(source_id,
                    target_id, transformation_icp, information_icp, uncertain=True))
    return pose_graph

こちらの関数も順に説明していきます。

pose_graph = o3d.pipelines.registration.PoseGraph()
odometry = np.identity(4)
pose_graph.nodes.append(o3d.pipelines.registration.PoseGraphNode(odometry))

空の PoseGraph を作成し、4×4 の単位行列を作成します。
この単位行列を 空のPoseGraph の先頭ノードに登録します。

次は、点群データの数だけ source と target をペアにしていきます。

transformation_icp, information_icp = pairwise_registration(
                pcds[source_id], pcds[target_id],
                max_correspondence_distance_coarse,
                max_correspondence_distance_fine)

pairwise_registration について説明していきます。

pairwise_registration

先に、コードを掲載します。

def pairwise_registration(source, target, max_correspondence_distance_coarse,
                          max_correspondence_distance_fine):
    print("Apply point-to-plane ICP")
    icp_coarse = o3d.pipelines.registration.registration_icp(
        source, target, max_correspondence_distance_coarse, np.identity(4),
        o3d.pipelines.registration.TransformationEstimationPointToPlane())
    icp_fine = o3d.pipelines.registration.registration_icp(
        source, target, max_correspondence_distance_fine,
        icp_coarse.transformation,
        o3d.pipelines.registration.TransformationEstimationPointToPlane())
    transformation_icp = icp_fine.transformation
    information_icp = o3d.pipelines.registration.get_information_matrix_from_point_clouds(
        source, target, max_correspondence_distance_fine,
        icp_fine.transformation)
    return transformation_icp, information_icp

pairwise_registration 関数は、source と target を引数とします。

icp_coarse = o3d.pipelines.registration.registration_icp(
        source, target, max_correspondence_distance_coarse, np.identity(4),
        o3d.pipelines.registration.TransformationEstimationPointToPlane())

o3d.pipelines.registration.registration_icp() はこちらの公式ドキュメントに記載があります。
o3d.pipelines.registration.TransformationEstimationPointToPlane() は、点群間の変換推定の方法を指定します。

ICPについては、こちらの Wikipedia を参照ください。
簡単に言うと、点群sourceと点群targetの各点を比較し、距離が最も近いものを同じ点とみなします。なので、大きなずれや角度変化に弱く、Realsense の撮影場所は近い必要があります。

今回のデータとは全くマッチしていません。改善策は別の記事にて説明します。

icp_fine = o3d.pipelines.registration.registration_icp(
        source, target, max_correspondence_distance_fine,
        icp_coarse.transformation,
        o3d.pipelines.registration.TransformationEstimationPointToPlane())

こちらも同じ関数です、与える変数が少し異なります。
icp_coarse で初期スクリーニングを行い、icp_fine で、本命の点を洗いだします。

戻り値は、変換に関する情報 (open3d.pipelines.registration.RegistrationResult) です。こちらに記載があります。

transformation_icp = icp_fine.transformation

変換行列を transformation_icp 変数に代入します。

information_icp = o3d.pipelines.registration.get_information_matrix_from_point_clouds(
        source, target, max_correspondence_distance_fine,
        icp_fine.transformation)

変換行列から情報行列を作成します。関数の詳細はこちらです。

full_registration 続き

pairwise_registration が完了したら、戻り値の変換行列と情報行列をしようして PoseGraph を作成していきます。

if target_id == source_id + 1:  # odometry case
    print("odometry case")
    odometry = np.dot(transformation_icp, odometry)
    pose_graph.nodes.append(o3d.pipelines.registration.PoseGraphNode(np.linalg.inv(odometry)))
    pose_graph.edges.append(o3d.pipelines.registration.PoseGraphEdge(source_id,
                                 target_id, transformation_icp, information_icp, uncertain=False))

隣り合う点群データの場合、(ICPならでは?) 単位行列と変換行列の積で odometry を更新します。
PoseGraphノードに更新した odometry の逆行列
PoseGraphエッジに sourceのID, targetのID, 変換行列、情報行列を登録します。source と隣り合う点群データなので、本当に先端エッジかどうかわかりません。この場合、Uncertain=False とします。詳細はこちらです。

else:  # loop closure case
    print("loop closure")
    pose_graph.edges.append(o3d.pipelines.registration.PoseGraphEdge(source_id,
                                     target_id, transformation_icp, information_icp, uncertain=True))

隣り合う点群データでない場合、PoseGraphエッジのみに登録を行います。エッジであることは明確なので、uncertain=True とします。

グローバル最適化

PoseGraph の作成が終わったら、グローバル最適化を実行します。
その前に、グローバル最適化用のオプションを作成します。

option = o3d.pipelines.registration.GlobalOptimizationOption(
        max_correspondence_distance=max_correspondence_distance_fine,
        edge_prune_threshold=0.25,
        reference_node=0)

o3d.pipelines.registration.GlobalOptimizationOption() の説明はこちらにあります。
使用する点群データが少ないので、とりあえずデフォルトにしておきます。

各引数は

  • max_correspondence_distance … 隣接点とみなす最大距離
  • edge_prune_threshold … 枝刈りのしきい値
  • reference_node … 固定するノード

となります。

o3d.pipelines.registration.global_optimization(
            pose_graph,
            o3d.pipelines.registration.GlobalOptimizationLevenbergMarquardt(),
            o3d.pipelines.registration.GlobalOptimizationConvergenceCriteria(),
            option)

o3d.pipelines.registration.global_optimization() は、作成した PoseGraph を最適化します。
詳細はこちらにあります。

o3d.pipelines.registration.GlobalOptimizationLevenbergMarquardt() は、最適化メソッドを指定します。GlobalOptimizationGaussNewton() もあります。

o3d.pipelines.registration.GlobalOptimizationConvergenceCriteria() は、収束基準を指定します。

最適化結果の表示

最後に、表示部分です。

print("Transform points and display")
for point_id in range(len(pcds_down)):
    print(pose_graph.nodes[point_id].pose)
    pcds_down[point_id].transform(pose_graph.nodes[point_id].pose)
o3d.visualization.draw_geometries(pcds_down)

pcds_down[point_id].transform(pose_graph.nodes[point_id].pose) で、元の点群データを変換します。

色付けしていない結果

改めて、合成前の結果を載せておきます。

合成結果は、以下のようになります。

別の角度から見てみます。

何となく合っています。

おわりに

今回は点群合成の方法について説明しました。
問題点は

  • PoseGraph作成メソッドが今回のデータに適していないこと
  • 点群データが少ないこと

が、挙げられます。

次回は、上記の問題点を改善しようと思います。

コメント

タイトルとURLをコピーしました