RealSense 複数カメラの ICP + FPFH キャリブレーション — ボード不要で現場展開
複数の深度カメラの座標系を点群ベースで統一する ICP キャリブレーションの実装メモ。FPFH による初期対応付け、PCL (C++) でのパイプライン、精度特性と誤差累積の実測値。
解きたい問題
複数の深度カメラ (Intel RealSense 等) を一列に配置し、長尺の物体全体を計測したい。 各カメラは独立した座標系で 3D 点群を返すので、それらを 1 つの共通座標系に揃える 必要があります。
従来は キャリブレーションボード (チェッカーボード・ArUco マーカー) を各カメラ間に置いて位置合わせする手法が定番でしたが、
- ボードの位置を物理的に調整する手間
- 現場 (工場ライン等) では毎回ボード設置が難しい
- カメラ数が増えると作業量が爆発
という課題がありました。本稿では 点群ベースの ICP + FPFH でボードなしキャリブレーションを行う実装を解説します。
参考: 点群ベースのマルチカメラキャリブレーション (JSPE 2017)
前提条件
本稿の内容は、以下のセットアップを前提にしています。
ハードウェア / 設置
- 深度カメラ (RealSense シリーズ等) を 2 台以上 使用
- カメラ同士の 視野を 30% 以上重ねて 配置 (完全に独立した視野だと特徴点の対応付けができない)
- 各カメラの設置位置はおおよそ ±30° 以内の精度で手動粗合わせ済 (ICP の初期値代わり)
- カメラは 起動後 10〜30 分ウォームアップ 済 (深度値ドリフト対策)
ソフトウェア
- C++17 対応コンパイラ (GCC 9+ / Clang 10+) と CMake 3.15+
- PCL (Point Cloud Library) 1.12+ (
apt install libpcl-devor source build) - 各カメラから
.pcd/.ply形式で点群を取得できる環境 (RealSense SDK:librealsense2+rs2::pointcloud)
計測シーン
- 対象物に 特徴のある形状 (角・溝・凹凸) が含まれている
- 計測中はカメラが動かない (三脚等で固定)
- 屋内 or 日陰 での計測 (直射日光は IR パターン投影式センサーの精度を大きく落とす)
必要な精度
- 本稿の手法で到達する精度は 1 ペアあたり ±15〜20mm 程度
- サブ mm の高精度が必要な用途 (精密加工・医療等) では、この手法では不足。構造光スキャナや産業用レーザートラッカーを検討
キャリブレーションをしないと何が起きるか
「各カメラが独立した 3D 座標で点群を返す」とはつまり、同じ対象物を計測しても カメラごとに違う座標値 が出力されるということです。
対象物 (実際の位置 X=1000mm)
CAM0 の座標系 → P = (120, 45, 950) ← CAM0 から見た相対座標
CAM1 の座標系 → P = (-230, 55, 1020) ← CAM1 から見た相対座標
CAM2 の座標系 → P = (-580, 48, 1010) ← CAM2 から見た相対座標
これらを そのまま統合しようとした場合 の実測影響:
| 作業 | キャリブなし | 本手法適用後 |
|---|---|---|
| 全長計測 (CAM0 の端 → CAM4 の端) | 数百 mm オーダーのズレ (各カメラ設置位置のラフ精度に完全依存) | ±60〜80 mm (累積誤差) |
| 複数カメラの点群統合 | 同じ対象物が 複数の場所に重複して見える | 1 つのクリーンな統合点群になる |
| 物体サイズ測定 | カメラ切替地点で寸法が破綻する | 連続した寸法が取れる |
つまり キャリブレーションは 「あった方が良い」 ではなく 「やらないと使い物にならない」 のがマルチカメラ計測の実情です。本稿の手法は、その必須作業を ボードなしで数分で実行可能にする のが目的です。
全体像
[CAM0] [CAM1] [CAM2] [CAM3] [CAM4]
📦 📦 📦 📦
↑ 隣接カメラの重複視野に段ボール等の特徴物を配置
- 全カメラが同じ段ボール (特徴の多い物体) を撮影
- 各点群から FPFH (Fast Point Feature Histograms) を計算して局所形状を特徴ベクトル化
- 隣接カメラ間で特徴ベクトルが近い点を対応付け → グローバルマッチング
- ICP (Iterative Closest Point) で精密位置合わせ
- 全カメラの変換行列 (R, t) が決まり、共通座標系に統一
ICP 単体だけだと初期姿勢が悪いと局所解に陥るので、FPFH で粗マッチ → ICP で精細化 の 2 段構えが実務の定番です。
FPFH とは
各 3D 点の周辺の形状を 33 次元のヒストグラム で表現する特徴量 (Rusu, 2009)。
CAM0 の点 P ──FPFH→ [0.1, 0.3, 0.0, 0.2, ..., 0.05] (33 次元)
CAM1 の点 Q ──FPFH→ [0.1, 0.3, 0.0, 0.2, ..., 0.06] (33 次元)
↑ ベクトルが近い → 対応点候補
同じ形状の角・エッジ・曲面は近い値になるので、「対応点を探す」問題がシンプルなベクトル距離計算に帰着します。
PCL (Point Cloud Library) による実装
C++ + PCL で書いた最小パイプラインを示します。実運用では C++ の方が RealSense SDK (librealsense2) と直接統合しやすく、エッジデバイス (Jetson / x86 IPC) でもネイティブに動きます。
CMakeLists.txt
cmake_minimum_required(VERSION 3.15)
project(icp_calibration)
find_package(PCL 1.12 REQUIRED COMPONENTS common io registration features filters)
add_executable(icp_calibration icp_calibration.cpp)
target_link_libraries(icp_calibration PRIVATE ${PCL_LIBRARIES})
target_include_directories(icp_calibration PRIVATE ${PCL_INCLUDE_DIRS})
icp_calibration.cpp
#include <pcl/point_types.h>
#include <pcl/point_cloud.h>
#include <pcl/io/pcd_io.h>
#include <pcl/filters/voxel_grid.h>
#include <pcl/features/normal_3d_omp.h>
#include <pcl/features/fpfh_omp.h>
#include <pcl/registration/sample_consensus_prerejective.h>
#include <pcl/registration/icp.h>
using PointT = pcl::PointXYZ;
using PointNormalT = pcl::PointNormal;
using FeatureT = pcl::FPFHSignature33;
static constexpr float VOXEL = 0.01f; // 1cm ダウンサンプリング
// ダウンサンプリング + 法線推定 + FPFH 計算
void preprocess(const pcl::PointCloud<PointT>::Ptr& in,
pcl::PointCloud<PointNormalT>::Ptr& out,
pcl::PointCloud<FeatureT>::Ptr& features) {
// ダウンサンプリング
pcl::PointCloud<PointT>::Ptr down(new pcl::PointCloud<PointT>);
pcl::VoxelGrid<PointT> vg;
vg.setInputCloud(in);
vg.setLeafSize(VOXEL, VOXEL, VOXEL);
vg.filter(*down);
// 法線推定
pcl::NormalEstimationOMP<PointT, PointNormalT> ne;
ne.setInputCloud(down);
ne.setRadiusSearch(VOXEL * 2.0);
pcl::PointCloud<pcl::Normal>::Ptr normals(new pcl::PointCloud<pcl::Normal>);
ne.compute(*normals);
out->resize(down->size());
for (size_t i = 0; i < down->size(); ++i) {
out->points[i].x = down->points[i].x;
out->points[i].y = down->points[i].y;
out->points[i].z = down->points[i].z;
out->points[i].normal_x = normals->points[i].normal_x;
out->points[i].normal_y = normals->points[i].normal_y;
out->points[i].normal_z = normals->points[i].normal_z;
}
// FPFH 特徴量
pcl::FPFHEstimationOMP<PointNormalT, PointNormalT, FeatureT> fpfh;
fpfh.setInputCloud(out);
fpfh.setInputNormals(out);
fpfh.setRadiusSearch(VOXEL * 5.0);
fpfh.compute(*features);
}
// FPFH によるグローバル (粗) マッチング: RANSAC Prerejective
Eigen::Matrix4f global_register(
const pcl::PointCloud<PointNormalT>::Ptr& src,
const pcl::PointCloud<PointNormalT>::Ptr& tgt,
const pcl::PointCloud<FeatureT>::Ptr& src_feat,
const pcl::PointCloud<FeatureT>::Ptr& tgt_feat) {
pcl::SampleConsensusPrerejective<PointNormalT, PointNormalT, FeatureT> align;
align.setInputSource(src);
align.setSourceFeatures(src_feat);
align.setInputTarget(tgt);
align.setTargetFeatures(tgt_feat);
align.setMaximumIterations(100000);
align.setNumberOfSamples(3);
align.setCorrespondenceRandomness(5);
align.setSimilarityThreshold(0.9f);
align.setMaxCorrespondenceDistance(VOXEL * 1.5f);
align.setInlierFraction(0.25f);
pcl::PointCloud<PointNormalT> aligned;
align.align(aligned);
return align.getFinalTransformation();
}
// ICP で精細化 (Point-to-Plane)
Eigen::Matrix4f refine_icp(
const pcl::PointCloud<PointNormalT>::Ptr& src,
const pcl::PointCloud<PointNormalT>::Ptr& tgt,
const Eigen::Matrix4f& init) {
pcl::IterativeClosestPointWithNormals<PointNormalT, PointNormalT> icp;
icp.setInputSource(src);
icp.setInputTarget(tgt);
icp.setMaxCorrespondenceDistance(VOXEL);
icp.setMaximumIterations(50);
icp.setTransformationEpsilon(1e-8);
pcl::PointCloud<PointNormalT> aligned;
icp.align(aligned, init);
std::cout << "ICP converged: " << icp.hasConverged()
<< ", fitness: " << icp.getFitnessScore() << std::endl;
return icp.getFinalTransformation();
}
int main() {
pcl::PointCloud<PointT>::Ptr cam0(new pcl::PointCloud<PointT>);
pcl::PointCloud<PointT>::Ptr cam1(new pcl::PointCloud<PointT>);
pcl::io::loadPCDFile("cam0.pcd", *cam0);
pcl::io::loadPCDFile("cam1.pcd", *cam1);
auto src = pcl::make_shared<pcl::PointCloud<PointNormalT>>();
auto tgt = pcl::make_shared<pcl::PointCloud<PointNormalT>>();
auto src_feat = pcl::make_shared<pcl::PointCloud<FeatureT>>();
auto tgt_feat = pcl::make_shared<pcl::PointCloud<FeatureT>>();
preprocess(cam0, src, src_feat);
preprocess(cam1, tgt, tgt_feat);
auto rough = global_register(src, tgt, src_feat, tgt_feat);
std::cout << "Rough transform:\n" << rough << std::endl;
auto fine = refine_icp(src, tgt, rough);
std::cout << "Final transform:\n" << fine << std::endl;
return 0;
}
ビルド & 実行
mkdir build && cd build
cmake ..
make -j$(nproc)
./icp_calibration
得られる fine は Eigen::Matrix4f の 4×4 剛体変換行列 (回転 R + 並進 t)。CAM0 の点を CAM1 の座標系に変換できます。
Jetson / 組み込みでの注意
- PCL のビルドは重い (数十分)。Jetson ではキャッシュイメージを使い回すか、
apt install libpcl-devで既存パッケージを利用 - OpenMP (
-fopenmp) を有効にしないと法線推定・FPFH が CPU 1 コアしか使えず遅い - RealSense SDK と組み合わせる場合は
rs2::pointcloud→pcl::PointCloud変換関数を別途用意する
カメラチェーンへの拡張
隣接ペアで変換行列を順次求め、最初のカメラを基準に連鎖させます。
T_01 : CAM0 → CAM1
T_12 : CAM1 → CAM2
T_23 : CAM2 → CAM3
CAM0 基準に全カメラの座標を揃える:
CAM0: I (単位行列)
CAM1: T_01
CAM2: T_12 @ T_01
CAM3: T_23 @ T_12 @ T_01
誤差の実測値 (断面測定想定)
| 項目 | 誤差 | 備考 |
|---|---|---|
| 深度センサー精度 (3m 想定) | ±10〜20 mm | センサー物理限界 |
| ICP キャリブレーション 1 ペアの RMSE | ±15〜20 mm | FPFH 粗マッチ → ICP 精細化後 |
| 4 ペア (CAM0→CAM4) の累積誤差 | ±60〜80 mm | 単純加算、実測はもう少し小さい |
累積誤差の軽減策:
- ループクロージャ: CAM0 と CAM4 の重複視野があれば閉ループで誤差分散
- Bundle Adjustment: 全ペアを同時最適化 (Open3D
multiway_registration) - カメラ数を減らす (センサーの有効距離を活かす)
現場適用のポイント
1. 段ボール・箱の配置
- 隣接カメラの重複視野 に置く (最低 1 ペアあたり 30% 以上の重複)
- 均一な面は特徴量が少なく対応付けに失敗 → 角・溝・凹凸のある物体 を選ぶ
- 動かないように固定 (計測中にずれると累積誤差)
2. ダウンサンプリング解像度
VOXEL = 1cmだと処理 1〜3 秒、精度 ±15mmVOXEL = 5mmだと処理 10 秒超、精度は数 mm 改善だが負荷大- 現場要件に合わせたトレードオフ を事前実験で決める
3. 外れ値除去
- RANSAC で粗外れを弾いた後、ICP でさらに閾値内の点だけで最適化
max_correspondence_distanceは「許容する対応距離」。センサー精度と同程度に設定
落とし穴
1. 初期姿勢が極端にズレていると収束しない
ICP は局所最適化なので、カメラ配置が物理的に大きくずれていると FPFH 粗マッチも失敗する。 カメラ設置時に ±30° 程度の精度で手動粗合わせ しておくと安定。
2. 深度センサーのウォームアップ
深度センサーは 起動後 10〜30 分でレーザー温度が安定 するまで深度値がドリフトする。 キャリブレーション前に必ずウォームアップ運用を。
3. 日光下での計測
太陽光 (特に直射) は IR パターン投影式センサーの精度を大きく落とす。 屋外用途なら ToF 系や LiDAR を検討。
4. 変換行列の保存と再読込
キャリブレーション結果は JSON / NumPy 配列で保存し、本番計測では再計算せず読み込む。
np.save("T_01.npy", fine.transformation)
# ...
T = np.load("T_01.npy")
5. 温度変化で再キャリブが必要
工場の季節変動 (気温 ±10℃) で機械的歪みが入り、変換行列が合わなくなることがある。 月次 or 季節変わり目に自動再キャリブ するフローを組む。
まとめ
- 複数 RealSense カメラの座標系統一は FPFH + ICP の 2 段パイプラインで、ボードなしで実現可能
- Open3D を使えば実装は数十行
- 1 ペアあたり ±15〜20mm の RMSE、カメラ数×ペア数 の累積誤差を見積もる
- 本番運用では 段ボール配置・ウォームアップ・定期再キャリブ を手順化
関連記事
- IoT・エッジ·
エッジ ⇔ クラウドの通信方式を選ぶ — MQTT / gRPC / WebSocket / mTLS
NAT 配下のエッジデバイスからクラウドへ安全・双方向に通信する方式を比較。Outbound long-lived gRPC bidi stream、MQTT、WebSocket、mTLS 認証、Agent パターンの選定基準と運用ポイント。
- クラウド·
Cassandra 一貫性レベルの選び方 — ONE から ALL まで、現場で何を使うか
Cassandra の Tunable Consistency を Read/Write 別に整理。QUORUM の数式、R+W>RF の原則、マルチ DC での LOCAL_QUORUM 推奨、トレードオフの実例つき。
- クラウド·
Cassandra データモデル入門 — AP 選択の帰結としてのマスターレス分散 DB
CAP 定理で AP を選んだ結果としての Cassandra アーキテクチャ。マスターレスのゴシップ通信、パーティション分散、レプリケーション戦略、ACID ではなく AID という表現までを RDBMS 比較で整理。