OpenCV を使って写真位置合わせプログラムを作ってみた

はじめに

株式会社ジャパン・インフラ・ウェイマークの川邉です。 当社はNTT西日本の子会社で、ドローン×画像解析AIを活用したインフラ点検を主に行っています。

本稿では、同じ場所を撮影した2枚の写真の特徴点を取得することで、同じような位置・角度で撮影した写真に変形するプログラムの仕組みとコードについて説明しています。類似のコードについて記載されているブログは他にも存在していますが、コピペで使えるレベルまで詳細コードを書いているページは見つからなかったので、テスト用に作成したコードをまるごと掲載しています。

対象読者

本記事が想定する対象読者は以下の通りです。

  • Python のプログラムを書くことができる
  • プログラムを用いた画像変形に興味がある

やりたいこと

画像解析AIの開発や検証を行っていると同じ場所で撮影された2枚の写真を正確に位置合わせしたいというようなシチュエーションが時々発生します。 例えば、同じテーブルの上を撮影した以下の2枚の写真があるとします。

変形前の画像

一見すると同じ写真なのですが、以下のように重ねてみると、撮影時の手振れなどで微妙に撮影位置・角度がズレていることが分かります。

重ねて表示

そこで基準写真(左の写真)となるべく一致するように対象写真(右の写真)を変形し、以下のようになるべく綺麗に重なる画像を作成することが今回の目的となります。

変形後の画像
変形後の画像を重ねて表示した結果

用語解説

本記事内で使用している用語の説明は以下の通りです。

用語 概要
OpenCV オープンソースの動画/画像処理ライブラリです
特徴量 画像の特徴を数値的に表現したものです。簡単な例としては色の分布や、エッジの向きなどがあります。何を特徴量とすると良いのかは、目的によって変わります
特徴点 画像内で特に目立ち、再検出しやすい点のことです。点周辺の特徴量と紐づいて決定されることが多いです
ホモグラフィ行列 平行移動、拡大/縮小、回転、反転などの幾何学的変換(ホモグラフィ変換)を行うための行列です。アフィン変換に似ていますが、平行が維持されるかどうかの点が異なります(アフィン変換の場合、平行線を変換した結果は必ず平行線になります)

開発方針

今回は以下のような手順で特徴点の抽出とマッチングを行い、画像変換用のホモグラフィ行列を作っていきます。

  1. SIFT_create(公式ドキュメント)で特徴量検出器を作成する
  2. detectAndCompute(公式ドキュメント) で各画像の特徴点と特徴量を取得する
  3. BFMatcher(公式ドキュメント) でマッチング器を作成し、上で取得した各画像の特徴量をマッチングする
  4. findHomography(公式ドキュメント) でホモグラフィ行列を計算する
  5. 上で作成したホモグラフィ行列を使って warpPerspective(公式ドキュメント) で画像を変形する

特徴量検出器

OpenCVでは特徴量検出器として、以下のようなものが使用可能です。

  • AKAZE (Accelerated KAZE)
  • SIFT (Scale-Invariant Feature Transform)
  • BRISK (Binary Robust Invariant Scalable Keypoints)
  • ORB (Oriented FAST and Rotated BRIEF)
  • SURF (Speeded-Up Robust Features)

このうち SURF はOpenCVの拡張モジュールのコード(参考) に

This algorithm is patented and is excluded in this configuration;

と記載されている通り、本記事執筆時点では特許が残っているため無料で利用できません。

無料で利用可能な残りの四つについては、

  • 人工物の多い写真は、直線やエッジに強い AKAZE
  • その他は SIFT

くらいのつもりで選定すれば良いと思います。なお、SIFT も以前はライセンスが必要でしたが、2020年3月7日に特許期間が満了しており、本記事執筆時点では無料で利用可能となっています(参考)。

特徴点のマッチング

特徴量検出器として SIFT を利用して、2枚の画像の特徴量を抽出し、互いにマッチングした結果が以下の通りです。

特徴点のマッチング

特徴量のマッチング器としては、大きく分けて FlannBasedMatcherBFMatcherの2種類があり、OpenCVの公式チュートリアル では FlannBasedMatcherを使用しているのですが、今回は BFMatcher の方を利用します。 理由は以下の通りです。

  • FlannBasedMatcherは大規模データ向き(BFMatcherより高速)だが、今回はそこまで大量のデータにはならない
  • FlannBasedMatcherは探索パラメータ(index_paramssearch_params)のチューニングが必要
  • FlannBasedMatcherはあくまで近似(Approximate)なので、厳密性は BFMatcher の方が高い

BFMatcher を使ってマッチング器を作成する際に crossCheck=True を指定することで、

  • 画像① の特徴点に対して最も近い 画像② の特徴点を検索した結果
  • 画像② の特徴点に対して最も近い 画像① の特徴点を検索した結果

が一致した場合のみを正解として検出するため、マッチング精度が上がります。ただ、上の結果を見ても分かる通り、crossCheck=True を指定しても意外とズレるので(例えば、テーブルに写っているコップの縁の辺りなど)、ホモグラフィ行列計算時にはこれらの誤マッチング部分(外れ値)を除く必要があります。

ホモグラフィ行列の作成

ホモグラフィ行列の作成には findHomographyというそのものずばりな名前の関数が用意されていますのでそれを利用します。 外れ値を除外するアルゴリズムとしては以下が利用可能です。

  • RANSAC (Random Sample Consensus)
  • LMeDS (Least Median of Squares)
  • RHO (PROSACベースの修正版RANSAC)

それぞれの使い分けについては、公式ドキュメントで以下の通り記載されています(公式ドキュメント

The methods RANSAC and RHO can handle practically any ratio of outliers but need a threshold to distinguish inliers from outliers. The method LMeDS does not need any threshold but it works correctly only when there are more than 50% of inliers.

要するに以下の通りです

  • RANSAC / RHO は外れ値が多くても使えるが、閾値設定が必要
  • LMeDS は閾値設定が不要だが、外れ値が半分以下の場合しか使えない

今回のコードでは RANSAC を利用しています。

内接矩形の計算

ホモグラフィ行列による変形を実施すると、どうしても変換元の画像には存在しなかった余白領域(下例の赤斜線部分)が発生してしまいます。用途によってはこの部分が問題になることがありますので、基準写真と変形後の対象写真の相互が有効な内接矩形(下例の青枠部分)も計算しておきます。

余白領域(赤斜線)と内接矩形(青枠)

計算手順は以下の通りです。

  1. 対象画像の四隅(左上、左下、右下、右上)の座標を取得する
  2. ホモグラフィ行列によって変換後の座標を取得する
  3. 内接矩形の左側の境界(左にある2点のうち、より「右」にあるもの)、右側の境界(右にある2点のうち、より「左」にあるもの)、上側の境界(上にある2点のうち、より「下」にあるもの)、下側の境界(下にある2点のうち、より「上」にあるもの)を取得する
  4. 上記と基準画像のキャンバスの重複領域を、内接矩形とする

完成物

完成したプログラムは以下の通りです。特徴点数に上限を設けたり、基準画像の読み込み処理を1回に抑えたりすることでもう少し高速化は可能ですが、そのあたりのチューニングは用途に合わせて実施してください。

ディレクトリ構成

align_image_project/
  ├── align_image/
  │   ├── align_image.py
  │   ├── __init__.py
  └── pyproject.toml

align_image.py

import cv2
import numpy as np
import os
import argparse
from typing import Optional, Tuple, Literal, get_args

# 利用可能な特徴量抽出器の種別
DetectorType = Literal["AKAZE", "ORB", "SIFT", "BRISK"]

# パスに全角文字を含む画像の読み込み
def __imread(path) -> np.ndarray:
    return cv2.imdecode(
        np.fromfile(
            path,
            dtype=np.uint8
        ),
        cv2.IMREAD_COLOR
    )

# パスに全角文字を含む画像の保存
def __imwrite(path, img) -> None:
    extension = os.path.splitext(path)[1]
    result, encoded_img = cv2.imencode(extension, img)
    if result:
        with open(path, mode="w+b") as f:
            encoded_img.tofile(f)

# 2枚の画像の位置補正
def align_image(
    base_img_path : str = None,
    target_img_path : str = None,
    img_base : np.ndarray = None,
    img_target : np.ndarray = None,
    detector_type: DetectorType = "SIFT",
    debug: bool = False
) -> Tuple[np.ndarray, Tuple[int, int, int, int], Optional[np.ndarray]]:

    if img_base is None:
        if not os.path.isfile(base_img_path):
            raise FileNotFoundError(f"ファイルが存在しません: {base_img_path}")
        img_base = __imread(base_img_path)

    if img_target is None:
        if not os.path.isfile(target_img_path):
            raise FileNotFoundError(f"ファイルが存在しません: {target_img_path}") 
        img_target = __imread(target_img_path)

    # グレースケールに変換(特徴点抽出のため)
    gray_base = cv2.cvtColor(img_base, cv2.COLOR_BGR2GRAY)
    gray_target = cv2.cvtColor(img_target, cv2.COLOR_BGR2GRAY)

    # 特徴点検出器の作成
    detector = (
        cv2.AKAZE_create() if detector_type == "AKAZE" else
        cv2.ORB_create() if detector_type == "ORB" else
        cv2.SIFT_create() if detector_type == "SIFT" else
        cv2.BRISK_create() if detector_type == "BRISK" else
        None
    )
    if detector is None:
        raise ValueError(f"不正な detector_type です: {detector_type}")

    keypoints1, descriptors1 = detector.detectAndCompute(gray_base, None)
    keypoints2, descriptors2 = detector.detectAndCompute(gray_target, None)

    # 特徴点自体が見つからない場合の例外処理
    if descriptors1 is None or descriptors2 is None:
        raise ValueError("特徴点が見つかりませんでした")

    # 特徴点のマッチング
    if descriptors1.dtype == np.uint8:
        # AKAZEやORBの場合
        matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
    else:
        # SIFTの場合 (float32型なのでL2ノルムを使用)
        matcher = cv2.BFMatcher(cv2.NORM_L2, crossCheck=True)

    matches = matcher.match(descriptors1, descriptors2)

    # デバッグ用の画像の保存
    debug_match_img = cv2.drawMatches(
        img_base, keypoints1, 
        img_target, keypoints2, 
        matches, None, 
        flags=cv2.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS
    ) if debug else None

    # 対応する点の座標を抽出
    points1 = np.zeros((len(matches), 2), dtype=np.float32)
    points2 = np.zeros((len(matches), 2), dtype=np.float32)

    for i, match in enumerate(matches):
        points1[i, :] = keypoints1[match.queryIdx].pt
        points2[i, :] = keypoints2[match.trainIdx].pt

    # 変換行列の計算
    h, mask = cv2.findHomography(
        points2,
        points1,
        cv2.RANSAC, # RANSACアルゴリズムを使用して外れ値を除外
        ransacReprojThreshold=3.0, # 許容誤差(ピクセル)
        maxIters=2000,             # 最大反復回数
        confidence=0.995           # 信頼度
    )
    if h is None:
        raise ValueError("変換行列の計算に失敗しました。点同士の整合性が取れません。")

    # 画像の変形
    h_base, w_base = img_base.shape[:2]
    aligned_img = cv2.warpPerspective(img_target, h, (w_base, h_base))

    # 内接矩形の計算
    h_tgt, w_tgt = img_target.shape[:2]

    # 変形前の四隅 [左上, 左下, 右下, 右上]
    corners = np.array([
        [0, 0],
        [0, h_tgt - 1],
        [w_tgt - 1, h_tgt - 1],
        [w_tgt - 1, 0]
    ], dtype=np.float32).reshape(-1, 1, 2)

    # 変換後の座標を取得
    t_corners = cv2.perspectiveTransform(corners, h).reshape(-1, 2)
    
    # x座標とy座標を分離
    tx = t_corners[:, 0]
    ty = t_corners[:, 1]

    # 「左側」「右側」「上側」「下側」の境界線を判定する
    # 左側の境界: 左にある2点のうち、より「右(max)」にあるもの
    sorted_x = np.sort(tx)
    left_edge = max(sorted_x[0], sorted_x[1])
    
    # 右側の境界: 右にある2点のうち、より「左(min)」にあるもの
    right_edge = min(sorted_x[2], sorted_x[3])
    
    # 上側の境界: 上にある2点のうち、より「下(max)」にあるもの
    sorted_y = np.sort(ty)
    top_edge = max(sorted_y[0], sorted_y[1])
    
    # 下側の境界: 下にある2点のうち、より「上(min)」にあるもの
    bottom_edge = min(sorted_y[2], sorted_y[3])

    # 基準画像のキャンバス内に限定
    left = max(0, left_edge)
    right = min(w_base, right_edge)
    top = max(0, top_edge)
    bottom = min(h_base, bottom_edge)

    # 整数型に変換
    left, top = int(np.ceil(left)), int(np.ceil(top))
    right, bottom = int(np.floor(right)), int(np.floor(bottom))

    # もし回転が大きすぎて幅や高さがマイナスになった場合
    if left >= right or top >= bottom:
        raise ValueError("有効な共通領域が見つかりませんでした。")
    
    return aligned_img, (left, top, right, bottom), debug_match_img

# コマンドライン実行時のエンドポイント
def endpoint() -> None:
    parser = argparse.ArgumentParser(description="2枚の画像の位置合わせを実施する")
    parser.add_argument("--base", required=True, help="ベースとなる画像のパス")
    parser.add_argument("--target", required=True, help="変形する画像もしくは、画像を格納しているディレクトリのパス")
    parser.add_argument("--output", default=None, help="結果保存先のディレクトリのパス")
    parser.add_argument("--trim", action="store_true", help="余白箇所を削除して画像サイズを合わせます")
    parser.add_argument("--debug", action="store_true", help="デバッグ用画像も保存する")
    parser.add_argument("--detector", type=str, default="SIFT", choices=get_args(DetectorType), help="検出器の種別")
    args = parser.parse_args()

    # ~ で始まるパスの展開
    base_path = os.path.expanduser(args.base)
    target_path = os.path.expanduser(args.target)

    # 変換対象画像のパスがディレクトリかどうかの確認
    target_is_dir = os.path.isdir(target_path)

    # 変換対象画像のパス一覧取得
    targets = [
        os.path.join(target_path, f) for f in os.listdir(target_path) if f.lower().endswith((".png", ".jpg", ".jpeg"))
    ] if target_is_dir else [target_path]

    if not len(targets):
        raise FileNotFoundError("変換対象の画像ファイルが見つかりませんでした")

    aligned_images = []
    rects = []
    debugs = []

    # 基準画像の読み込み(複数回利用するので、ここで読み込んだものを再利用する)
    img_base = __imread(base_path)

    # 変換画像に対する変換処理の実施
    for target in targets:
        aligned_img, rect, debug_img = align_image(
            img_base = img_base,
            target_img_path = target,
            debug = args.debug,
            detector_type = args.detector
        )
        aligned_images.append(aligned_img)
        rects.append(rect)
        debugs.append(debug_img)

    # 出力先フォルダのパス
    output_dir = (
        args.output if args.output is not None else
        args.target if target_is_dir else
        os.path.dirname(args.target)
    )

    # 共通のトリミング範囲
    trim_rect = [
        max([r[0] for r in rects]),
        max([r[1] for r in rects]),
        min([r[2] for r in rects]),
        min([r[3] for r in rects])
    ] if args.trim else None

    if trim_rect is not None and (trim_rect[0] >= trim_rect[2] or trim_rect[1] >= trim_rect[3]):
        print(f"トリミング範囲が不正です: {trim_rect}")
        trim_rect = None

    # もしも保存先のディレクトリが存在しない場合は作成
    if output_dir and not os.path.exists(output_dir):
        os.makedirs(output_dir)

    # 出力先ファイルパスの作成
    def create_unique_path(
        dir_path: str,
        filename: str
    ):
        path = os.path.join(
            dir_path,
            filename
        )

        if os.path.exists(path):
            base, ext = os.path.splitext(path)
            counter = 2
            while os.path.exists(f"{base}_{counter}{ext}"):
                counter += 1
            path = f"{base}_{counter}{ext}"
        
        return path

    for aligned_img, target, debug_img in zip(aligned_images, targets, debugs):
        # 元のファイルの名前の取得
        target_filename = os.path.basename(target)
        
        # デバッグ用画像の出力
        if args.debug:
            # 特徴点同士のマッチング画像の保存
            __imwrite(create_unique_path(
                output_dir,
                f"debug_matches_{target_filename}"
            ), debug_img)

            # 変形前画像と基準画像のオーバーレイの保存
            target_img = __imread(target)
            target_img = cv2.resize(target_img, (img_base.shape[1], img_base.shape[0]))
            __imwrite(create_unique_path(
                output_dir,
                f"debug_overlay_target_{target_filename}"
            ), target_img + img_base / 2)

            # 変形後画像と基準画像のオーバーレイの保存
            __imwrite(create_unique_path(
                output_dir,
                f"debug_overlay_aligned_{target_filename}"
            ), aligned_img + img_base / 2)
        
        # トリミングの実施
        if trim_rect is not None:
            x_min, y_min, x_max, y_max = trim_rect
            aligned_img = aligned_img[y_min:y_max, x_min:x_max]

        # ファイルの保存
        __imwrite(create_unique_path(
            output_dir,
            f"aligned_{target_filename}"
        ), aligned_img)

    # トリミング行う場合はオリジナルの画像もトリミングしておく
    if trim_rect is not None:
        x_min, y_min, x_max, y_max = trim_rect
        timmed_img_base = img_base[y_min:y_max, x_min:x_max]

        # ファイルの保存
        __imwrite(create_unique_path(
            output_dir,
            f"trimmed_{os.path.basename(base_path)}"
        ), timmed_img_base)


if __name__ == "__main__":
    endpoint()

__init__.py

__version__ = "0.1.0"
from .align_image import align_image

pyproject.toml

[tool.poetry]
name = "align_image"
version = "0.1.0"
description = "2枚の画像の位置合わせを実施する"
authors = ["Your Name <you@example.com>"]

[tool.poetry.dependencies]
python = ">=3.10,<3.12"
opencv-python = "<4.11.0"
numpy = "<2.0.0"

[tool.poetry.dev-dependencies]

[tool.poetry.scripts]
align_image = "align_image.align_image:endpoint"

[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

使い方

Poetry 仮想環境上にインストールする場合

pyproject.toml を使うことで各モジュールを管理していますので、pyproject.tomlの存在しているディレクトリで

poetry install

を実行すれば必要なモジュールがインストールされます。

tool.poetry.scripts でコンソールコマンドとして登録していますので、以下のようにコマンドラインから実行することが可能です

poetry run align_image --base "基準画像のパス" --target "変形する画像 or ディレクトリのパス" --output "結果保存先のディレクトリのパス"

プライベートパッケージとしてインストールする場合

上記のコードを align_image_project というディレクトリ上に展開した上で

pip install ./align_image_project

のようにインストールすることで以下のように Python のプログラムで読み込んで利用することが可能です(展開先のディレクトリ名は任意ですが、インストール後に import するモジュール名は必ず align_image としてください)。

from align_image import align_image

aligned_image, rect, _ = align_image(
    base_img_path = "基準画像のパス",
    target_img_path = "変形する画像のパス"
)

まとめ

本項では OpenCV を利用した画像の補正について記載しました。特徴量の取得やマッチングに関する記事は既に複数存在していますが、内容が古かったり、マッチング器の比較までは記載されていなかったりなどしますので、多少は独自の記事になったかと思います。

免責事項

  • 記事に記載されている各ライブラリの仕様などは予告なく変更される場合があります
  • 本記事の内容を実践される際は、必ず OpenCV の該当するバージョンに関する最新の公式ドキュメントをご確認ください
  • 本記事の情報に基づいて行われた意思決定や実装により生じた損害について、筆者および所属組織は一切の責任を負いかねます

参考資料・出典

本記事の執筆にあたり参考としたページは以下の通りです

執筆者

川邉 隆伸 (ジャパン・インフラ・ウェイマーク開発部所属)

画像認識系AIの開発や、それらを提供するSaaS環境の構築を行っています。

商標

  • Python は Python Software Foundation の商標もしくは登録商標です
  • OpenCV は Open Source Vision Foundation の商標もしくは登録商標です

© NTT WEST, Inc.