OpenCV を使わない画像の逆透視変換
はじめに
自動運転技術の発展に伴い、多くの人々が新しいテクノロジーに触れる機会が増え、コンピュータの世界でどのように自動運転が実現されているのか、また自動運転システムに付随する特定の機能がどのように実装されているのかについて、多くの人が興味を持つようになりました。今回は、システム内の 「360°バックカメラ映像」 の背後にあるアルゴリズムのロジックについて解説し、実際に実装してみます。
逆透視変換
映像を取得する際、車両は複数のカメラを呼び出し、それらをつなぎ合わせて「360°パノラマ写真」を生成します。
そして、俯瞰視点の 「360°バックカメラ映像」 を生成するには、数学的な変換、すなわち逆透視変換(IPM)を経る必要があります。
この分野には、IPM変換には「対応点ペアを用いたホモグラフィ変換法」、「簡略化カメラモデルによる逆透視変換」など、さまざまな方法がありますが、いずれも行列の変換則を利用しています。
対応点ペアを用いたホモグラフィ変換法
この変換方法は比較的シンプルなので、詳しい説明は省略します。
少なくとも4組の対応点ペアを入力します。このとき、3点以上が一直線上に並んではいけません。カメラパラメータや平面の位置に関する情報は不要で、点ペアを用いて透視変換行列を求めます。この行列は3次の正方行列であるため、線形方程式を構築して解くことができます。4点より多い場合は、$ransac$ 法を用いて解くことができます。点の選定は通常、手動で行い、一般的には消失点を選択します。

この変換はコードでの実装が比較的簡単で、IPM変換を容易に実現できます。ここでは詳しく説明せず、コード例も提供しません。
簡略化カメラモデルによるIPM法
今回はこの変換方法を重点的に解説します。このアルゴリズムの本質は、カメラの撮像過程における様々な座標間の変換関係を利用し、それを抽象化・簡略化して、最終的な世界座標を求めることにあります。
そして、世界座標と画像座標の対応関係を確立し、その関係を用いて数学的変換を行います。

複雑で冗長な計算式とは異なり、ここでも座標計算を用います。このIPM計算方法では、まずカメラの実際のパラメータを測定する必要があります。
ここでは、仰角 は 、中心高さ は 、視点から視平面までの距離 は とし、世界座標 を求めます。
カメラの画像座標を とし、世界座標と画像座標の関係から行列方程式を構築します。
画像座標を式$(1)$に代入して、世界座標の行列を求めます。
、$B = -d$、$C = d\sin\theta - \frac{H}{d}$、$D = \cos\theta$、$E = d\sin\theta$ とおきます。幾何学的関係から が成り立ちます。$\boldsymbol{P_W}$ の最も簡潔な形は次のようになります。
最後に、画像を処理します。処理する画像は2次元平面図であるため、画像の奥行きは常に0です。$(3)$に基づき、配列の横縦座標を代入するだけで、世界座標系での座標値、すなわちIPM後の俯瞰図を求めることができます。

#include <cmath>
#include <cstdint>
#include <vector>
#include <algorithm>
namespace ipm
{
// =========================
// 基礎データ構造
// =========================
struct Vec3
{
double x;
double y;
double z;
};
struct GroundPoint
{
double X; // 世界座標 X(左右)
double Y; // 世界座標 Y(前後)
bool valid; // 地面との有効な交点があるかどうか
};
struct CameraParam
{
// 焦点距離(ピクセル単位)
// d しかない場合は、fx = fy = d と設定できます
double fx;
double fy;
// 主点(通常は画像の中心)
double cx;
double cy;
// カメラの地上高(単位は例えば cm)
double H;
// カメラの下向き俯角(ラジアン)
double pitch;
};
struct IPMParam
{
// 出力俯瞰図のサイズ
int outWidth;
int outHeight;
// 世界座標の範囲(単位は H と統一、例えば cm)
// X: 左右範囲
// Y: 前後範囲
double minX;
double maxX;
double minY;
double maxY;
};
// =========================
// ユーティリティ関数
// =========================
inline double clampDouble(double v, double lo, double hi)
{
return (v < lo) ? lo : ((v > hi) ? hi : v);
}
inline uint8_t clampToByte(double v)
{
if (v < 0.0) return 0;
if (v > 255.0) return 255;
return static_cast<uint8_t>(v + 0.5);
}
// X 軸周りの回転:カメラ座標系の方向を世界座標系に変換
// ここでは以下を仮定:
// - 世界の Z 軸は上向き
// - カメラの光軸はデフォルトで世界の Y 正方向を向く
// - pitch > 0 はカメラが下を向いていることを示す
//
// 画像座標(v が下向き)と整合させるために、工学的に一般的なマッピングを構築:
//
// カメラ系のレイ rc = [x, y, 1]
// まず「ピッチなし」の世界方向にマッピング:
// x -> Xw
// y -> -Zw
// z -> Yw
//
// 次に世界の X 軸周りに pitch だけ回転
//
inline Vec3 cameraRayToWorldRay(const Vec3& rc, double pitch)
{
// ピッチなしの世界方向
// カメラ右 -> 世界右
// カメラ下 -> 世界の負の上
// カメラ前 -> 世界前
const double X0 = rc.x;
const double Y0 = rc.z;
const double Z0 = -rc.y;
const double c = std::cos(pitch);
const double s = std::sin(pitch);
// X 軸周りに回転
Vec3 rw;
rw.x = X0;
rw.y = c * Y0 - s * Z0;
rw.z = s * Y0 + c * Z0;
return rw;
}
// =========================
// ピクセル点 -> 地面の世界座標
// =========================
//
// 入力ピクセル点 (u, v) を、地面 Z=0 上の対応する世界点 (X, Y) に変換
//
// 注意:
// 1. このレイが上向きまたは地面と平行な場合は invalid
// 2. fx, fy はピクセル単位
// 3. H の単位が出力世界座標の単位を決定
//
inline GroundPoint imagePixelToGround(
double u,
double v,
const CameraParam& cam)
{
// 1) ピクセル座標 -> カメラ正規化座標
Vec3 rc;
rc.x = (u - cam.cx) / cam.fx;
rc.y = (v - cam.cy) / cam.fy;
rc.z = 1.0;
// 2) カメラレイ -> 世界レイ
Vec3 rw = cameraRayToWorldRay(rc, cam.pitch);
// 3) 世界座標系におけるカメラ中心の位置
// Cw = (0, 0, H)
// レイ方程式:P(t) = Cw + t * rw
//
// 地面 Zw = 0 との交点:
// H + t * rw.z = 0 => t = -H / rw.z
//
GroundPoint gp{};
gp.valid = false;
// レイが地面を向いていない、または地面とほぼ平行
if (std::abs(rw.z) < 1e-12)
return gp;
const double t = -cam.H / rw.z;
// 「前方」の交点のみ受け入れる
if (t <= 0.0)
return gp;
gp.X = t * rw.x;
gp.Y = t * rw.y;
gp.valid = true;
return gp;
}
// =========================
// 世界座標 -> 俯瞰図ピクセル
// =========================
//
// 地面点 (X, Y) を出力俯瞰図の (bx, by) にマッピング
//
// 出力図の約束:
// - 左端は minX、右端は maxX
// - 上端は maxY(より遠く)
// - 下端は minY(より近く)
//
inline bool groundToBirdPixel(
double X, double Y,
const IPMParam& ipmParam,
double& bx, double& by)
{
if (X < ipmParam.minX || X > ipmParam.maxX ||
Y < ipmParam.minY || Y > ipmParam.maxY)
{
return false;
}
const double xRatio =
(X - ipmParam.minX) / (ipmParam.maxX - ipmParam.minX);
const double yRatio =
(Y - ipmParam.minY) / (ipmParam.maxY - ipmParam.minY);
// X は左から右へ
bx = xRatio * (ipmParam.outWidth - 1);
// 「遠くが画像の上」になるように
by = (1.0 - yRatio) * (ipmParam.outHeight - 1);
return true;
}
// =========================
// バイリニアサンプリング(グレースケール)
// =========================
inline uint8_t bilinearSampleGray(
const uint8_t* src,
int width,
int height,
int stride,
double u,
double v)
{
if (u < 0.0 || v < 0.0 || u > width - 1.0 || v > height - 1.0)
return 0;
const int x0 = static_cast<int>(std::floor(u));
const int y0 = static_cast<int>(std::floor(v));
const int x1 = std::min(x0 + 1, width - 1);
const int y1 = std::min(y0 + 1, height - 1);
const double dx = u - x0;
const double dy = v - y0;
const double p00 = src[y0 * stride + x0];
const double p10 = src[y0 * stride + x1];
const double p01 = src[y1 * stride + x0];
const double p11 = src[y1 * stride + x1];
const double v0 = p00 * (1.0 - dx) + p10 * dx;
const double v1 = p01 * (1.0 - dx) + p11 * dx;
const double val = v0 * (1.0 - dy) + v1 * dy;
return clampToByte(val);
}
// =========================
// バイリニアサンプリング(RGB 3チャンネル)
// 1ピクセルあたり3バイト、RGBRGB...
// =========================
inline void bilinearSampleRGB(
const uint8_t* src,
int width,
int height,
int stride,
double u,
double v,
uint8_t outRGB[3])
{
if (u < 0.0 || v < 0.0 || u > width - 1.0 || v > height - 1.0)
{
outRGB[0] = outRGB[1] = outRGB[2] = 0;
return;
}
const int x0 = static_cast<int>(std::floor(u));
const int y0 = static_cast<int>(std::floor(v));
const int x1 = std::min(x0 + 1, width - 1);
const int y1 = std::min(y0 + 1, height - 1);
const double dx = u - x0;
const double dy = v - y0;
const uint8_t* p00 = src + y0 * stride + x0 * 3;
const uint8_t* p10 = src + y0 * stride + x1 * 3;
const uint8_t* p01 = src + y1 * stride + x0 * 3;
const uint8_t* p11 = src + y1 * stride + x1 * 3;
for (int c = 0; c < 3; ++c)
{
const double v0 = p00[c] * (1.0 - dx) + p10[c] * dx;
const double v1 = p01[c] * (1.0 - dx) + p11[c] * dx;
const double val = v0 * (1.0 - dy) + v1 * dy;
outRGB[c] = clampToByte(val);
}
}
// =========================
// 俯瞰図ピクセル -> 世界座標
// =========================
//
// 「逆マッピング」の鍵:
// 出力俯瞰図の各ピクセルについて、まずその世界地面上の点を求め、
// 次に元画像での位置を逆算し、最後に元画像からサンプリングします。
//
inline void birdPixelToGround(
double bx,
double by,
const IPMParam& ipmParam,
double& X,
double& Y)
{
const double xRatio = bx / (ipmParam.outWidth - 1);
const double yRatio = 1.0 - by / (ipmParam.outHeight - 1);
X = ipmParam.minX + xRatio * (ipmParam.maxX - ipmParam.minX);
Y = ipmParam.minY + yRatio * (ipmParam.maxY - ipmParam.minY);
}
// =========================
// 世界地面点 -> 元画像ピクセル
// =========================
//
// 既知の世界点 (X, Y, 0) を入力画像に逆投影し、逆マッピングサンプリングを容易にします。
//
inline bool groundToImagePixel(
double X,
double Y,
const CameraParam& cam,
double& u,
double& v)
{
// 世界点 Pw = (X, Y, 0)
// カメラ中心 Cw = (0, 0, H)
// 世界方向ベクトル d_w = Pw - Cw = (X, Y, -H)
const double dwx = X;
const double dwy = Y;
const double dwz = -cam.H;
// 世界方向をカメラ方向に戻す必要があります
// cameraRayToWorldRay では:Rw = Rx(pitch) * base
// したがって、ここでは逆回転:Rx(-pitch)
const double c = std::cos(cam.pitch);
const double s = std::sin(cam.pitch);
// まずピッチなし状態に逆回転
const double X0 = dwx;
const double Y0 = c * dwy + s * dwz;
const double Z0 = -s * dwy + c * dwz;
// カメラ座標に再マッピング
// base: [X0, Y0, Z0] = [xc, zc, -yc]
const double xc = X0;
const double yc = -Z0;
const double zc = Y0;
// カメラの後方にある場合は無効
if (zc <= 1e-12)
return false;
u = cam.fx * (xc / zc) + cam.cx;
v = cam.fy * (yc / zc) + cam.cy;
return true;
}
// =========================
// グレースケール IPM
// =========================
//
// src: 入力グレースケール画像
// dst: 出力グレースケール画像。外部で outHeight * dstStride バイトを割り当てる必要があります
//
inline void warpIPMGray(
const uint8_t* src,
int srcWidth,
int srcHeight,
int srcStride,
uint8_t* dst,
int dstStride,
const CameraParam& cam,
const IPMParam& ipmParam)
{
for (int by = 0; by < ipmParam.outHeight; ++by)
{
uint8_t* dstRow = dst + by * dstStride;
for (int bx = 0; bx < ipmParam.outWidth; ++bx)
{
// 1) 出力俯瞰図ピクセル -> 世界地面点
double X, Y;
birdPixelToGround(static_cast<double>(bx),
static_cast<double>(by),
ipmParam, X, Y);
// 2) 世界地面点 -> 元画像ピクセル
double u, v;
if (!groundToImagePixel(X, Y, cam, u, v))
{
dstRow[bx] = 0;
continue;
}
// 3) バイリニアサンプリング
dstRow[bx] = bilinearSampleGray(src, srcWidth, srcHeight, srcStride, u, v);
}
}
}
// =========================
// RGB 画像 IPM
// =========================
//
// src: 入力 RGB 画像、RGBRGB... の順
// dst: 出力 RGB 画像、RGBRGB... の順
//
inline void warpIPMRGB(
const uint8_t* src,
int srcWidth,
int srcHeight,
int srcStride,
uint8_t* dst,
int dstStride,
const CameraParam& cam,
const IPMParam& ipmParam)
{
for (int by = 0; by < ipmParam.outHeight; ++by)
{
uint8_t* dstRow = dst + by * dstStride;
for (int bx = 0; bx < ipmParam.outWidth; ++bx)
{
double X, Y;
birdPixelToGround(static_cast<double>(bx),
static_cast<double>(by),
ipmParam, X, Y);
double u, v;
if (!groundToImagePixel(X, Y, cam, u, v))
{
uint8_t* p = dstRow + bx * 3;
p[0] = p[1] = p[2] = 0;
continue;
}
uint8_t rgb[3];
bilinearSampleRGB(src, srcWidth, srcHeight, srcStride, u, v, rgb);
uint8_t* p = dstRow + bx * 3;
p[0] = rgb[0];
p[1] = rgb[1];
p[2] = rgb[2];
}
}
}
}