不確定な世界

科学の話題を中心に、勉強したことや考えたことを残していきたいと思います

「詳解3次元点群処理」;金崎朝子・秋月秀一・千葉直也著 読書感想

本日紹介するのは、金崎朝子・秋月秀一・千葉直也著「詳解3次元点群処理」。
著者陣は全員ロボティクス方面の研究者。私にとってはほとんど馴染みのない分野だが、素人なりにSFアニメや科学番組でこの手の3次元データ処理のような場面を見る度にかっこいいという厨二的な憧れがあった。最近、私のような専門外の人間でも読みやすそうな本が出版されたので、ちょっとした冒険のつもりで読んでみた。

本書はコーディングがメインのソフトウェアの技術書であり、特にPythonのOpen3Dライブラリの入門書という側面が強い。センサなどハード面については紹介のみで、サンプルデータは全て公開データを用いているので、実際にセンサを持っている必要はない。また理論面の説明もそれほど多くはないのだが、門外漢の私には目新しい知識が多く、非常に勉強になった。理論面で特に印象に残ったのは以下の2つ。

回転行列、オイラー角、クォータニオンの関係

この3つの回転の表現方法について、私は知識としてそういうものがあるとは認識していた*1。しかしそれはあくまでも点の知識であり、なぜ似たような概念があるのか、何が違うのかを疑問に思ったことすらなかった。本書を読むことで、今まで点在していた知識が整理されてつながり、線や面になった感覚がある。

  • (3次元)回転行列の要素数3x3=9だが、回転という物理現象を表すための制約条件から、自由度は3しかない。つまり、回転行列は回転という操作を表すには冗長な表現である。
  • オイラー角は自由度3に対して3変数の表現。最もコンパクトで無駄はないが特異点があり、その付近では解が不安定になる。
  • クォータニオンは4変数で無駄が少なく、特異点もない。また、クォータニオンは球面線形補完が可能であり、アニメーションを扱うのに便利、というのが最大の利点である。

点群の法線ベクトルの求め方

点群の法線ベクトルを求めるには、ある注目点の近傍点を求め、近傍点群の3次元座標に対して主成分分析を行う。固有値が最も小さい軸(近傍点群の座標の分散が最も小さい、つまり「その方向に点群が平べったく分布している」軸)がその注目点の法線ベクトルになる。機械学習分野の次元削減の観点では、固有値が大きい(分散が大きい)方が情報量が多いと解釈するのだが、目的によっては分散が小さい方に利用価値があるという点が、私にとっては新鮮で、面白い!

Google Colabでの可視化

本書のサンプルコードでは可視化はopen3d.visualizationモジュールを用いて、GUI Windowで行っている。私はGoogle Colabでプログラムを動かすために、Plotlyを用いて可視化を行った。
open3d.visualization.draw_giometries()関数の代わりとしては、こちらのページのdraw_geometries()関数をベースに、以下のような関数を作成した。

import plotly.graph_objects as go
import open3d as o3d
import numpy as np

def draw_geometries(geometries, show=True):
    def cubes(size, pos_x, pos_y, pos_z, color):
        # https://community.plotly.com/t/plotly-graph-objects-volume-cube/75455/4
        x, y, z = np.meshgrid(
            np.linspace(pos_x-size/2, pos_x+size/2, 2),
            np.linspace(pos_y-size/2, pos_y+size/2, 2),
            np.linspace(pos_z-size/2, pos_z+size/2, 2),
        )
        x, y, z = x.flatten(), y.flatten(), z.flatten()
        R, G, B = color
        return go.Mesh3d(x=x, y=y, z=z, alphahull=1, flatshading=True, color=f"rgb({R},{G},{B})")

    graph_objects = []
    for geometry in geometries:
        geometry_type = geometry.get_geometry_type()

        if geometry_type == o3d.geometry.Geometry.Type.PointCloud:
            points = np.asarray(geometry.points)
            colors = None
            if geometry.has_colors():
                colors = np.asarray(geometry.colors)
            elif geometry.has_normals():
                colors = (0.5, 0.5, 0.5) + np.asarray(geometry.normals) * 0.5
            else:
                geometry.paint_uniform_color((1.0, 0.0, 0.0))
                colors = np.asarray(geometry.colors)

            scatter_3d = go.Scatter3d(x=points[:,0], y=points[:,1], z=points[:,2], mode='markers', marker=dict(size=1, color=colors))
            graph_objects.append(scatter_3d)

        if geometry_type == o3d.geometry.Geometry.Type.TriangleMesh:
            triangles = np.asarray(geometry.triangles)
            vertices = np.asarray(geometry.vertices)
            colors = None
            if geometry.has_triangle_normals():
                colors = (0.5, 0.5, 0.5) + np.asarray(geometry.triangle_normals) * 0.5
                colors = tuple(map(tuple, colors))
            elif geometry.has_vertex_colors(): # 第3章で追加
                colors = np.asarray(geometry.vertex_colors)
            else:
                colors = (1.0, 0.0, 0.0)

            mesh_3d = go.Mesh3d(x=vertices[:,0], y=vertices[:,1], z=vertices[:,2], i=triangles[:,0], j=triangles[:,1], k=triangles[:,2], facecolor=colors, opacity=0.50)
            graph_objects.append(mesh_3d)

        # 追加
        if geometry_type == o3d.geometry.Geometry.Type.LineSet:
            lines = np.asarray(geometry.lines)
            points = np.asarray(geometry.points)
            colors = None
            if geometry.has_colors():
                colors = (np.asarray(geometry.colors) * 255).astype(int)
            else:
                geometry.paint_uniform_color((0.0, 0.0, 0.0))
                colors = (np.asarray(geometry.colors) * 255).astype(int)

            for (i, j), (R, G, B) in zip(lines, colors):
                line_3d = go.Scatter3d(
                    x=points[[i, j], 0], y=points[[i, j], 1], z=points[[i, j], 2], mode="lines",
                    line=dict(width=1, color=f"rgb({R},{G},{B})")
                )
                graph_objects.append(line_3d)

        # 追加
        if geometry_type == o3d.geometry.Geometry.Type.VoxelGrid:
            centers = []
            colors = []
            has_colors = geometry.has_colors()
            for voxel in geometry.get_voxels():
                center = np.asarray(geometry.get_voxel_center_coordinate(voxel.grid_index))
                centers.append(center)
                if has_colors:
                    colors.append(np.asarray(voxel.color))
                else:
                    colors.append((0.0, 0.0, 0.0))
            centers = np.asarray(centers)
            colors = (np.asarray(colors) * 255).astype(np.uint8)
            voxel_size = geometry.voxel_size
            for center, color in zip(centers, colors):
                cube = cubes(voxel_size, center[0], center[1], center[2], color=color)
                graph_objects.append(cube)

    fig = go.Figure(
        data=graph_objects,
        layout=dict(
            scene=dict(
                xaxis=dict(visible=False),
                yaxis=dict(visible=False),
                zaxis=dict(visible=False)
            )
        )
    )
    if show:
        fig.show()
    else:
        return fig, graph_objects

また、第4章のためにPlotlyでアニメーションを表示する部分はこちらのページを参考にした。

# フレームの作成
pcds = reg.pcds
indices = reg.closest_indices
pcd_t = reg.pcd_t
line_list = [GetCorrespondenceLines(pcd_s, pcd_t, index) for pcd_s, index in zip(pcds, indices)]

frames = [
    go.Frame(
        data=draw_geometries([pcd_t, pcd_s, lines], show=False)[1],
        layout=dict(
            scene=dict(
                xaxis=dict(visible=False),
                yaxis=dict(visible=False),
                zaxis=dict(visible=False)
            )
        )
    )
    for pcd_s, lines in zip(pcds, line_list)
]
# アニメーションの表示
fig, _ = draw_geometries([pcd_t, pcds[0], line_list[0]], show=False)
fig.update_layout(
    title="ICP Animation",
    title_x=0.5,
    width=600,
    height=600,
    updatemenus=[
        dict(
            buttons=[
                dict(
                    args=[
                        None,
                        {
                              "frame": {"duration": 50, "redraw": True},
                              "fromcurrent": True,
                              "transition": {"duration": 0}
                        }
                    ],
                    label="Play",
                    method="animate"
                )
            ],
            type='buttons',
            showactive=False,
            y=1,
            x=1.8,
            xanchor='right',
            yanchor='top'
        )
    ]
)

fig.update(frames=frames)
fig.show()

最後に

自分にとって未知の分野であり、きちんと理解できたかは怪しいものの、久々に「新しいことを学んだ」という感触があった。この手の分野は環境構築が難しいイメージがあったが、本書のサンプルコードはPythonのみで完結し、Open3Dもpipで簡単にインストールできるので、専門外の人でも簡単に遊ぶことができる。3次元データをぐりぐり動かすだけでも面白いので、興味のある方がいたらぜひ読んでみてほしい。

*1:余談だが、回転行列に余分な1がついていることについて、学生時代に習ったときにはそういうものだと深く考えずに暗記に徹した。今回改めて回転行列を目にすると、パーセプトロンを実装するときにバイアス項も含めて行列積にまとめられるようにダミーで1をつけるのと全く同じことだと納得した