python/opencvによる射影変換と合成写真の作成

プログラミングカテゴリー

pythonのopencvを利用して射影変換をやっていようと思います。射影変換だけだと面白くないので、射影変換された画像を異なる画像に張り付けるということもやってみます。

こんな感じです。画像はpixabayから用意しました。

実行環境

OSはWindows10 + WSL2 (ubuntu20.04)を利用しています。

PythonはPipenvを利用して、バージョンは3.8を利用しています。

ディレクトリ構造

paste_image.py内に主な処理を記述しています。

$ tree
.
|-- Pipfile
|-- Pipfile.lock
|-- data
|   |-- back.jpg
|   `-- overlay.jpg
|-- main.py
`-- paste_image.py

pythonの仮想環境(Pipfile)

dev-packagesにコードフォーマット用のautopep8とflake8を入れています。コードフォーマットがいらない人には、必要ありません。

[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[packages]
opencv-contrib-python = "*"
numpy = "*"
pillow = "*"

[dev-packages]
autopep8 = "*"
flake8 = "*"

[requires]
python_version = "3.8"

pipenvのインストール方法や簡単な使い方について、下記でまとめているので参考にしてください。

射影変換と画像を重ね合わせる

それではプログラムの説明に入っていきます。主な処理部分のみ説明していきます。

全体のコードは下にまとめてあるので、そちらを参考にしてください。

opencvを利用した射影変換

射影変換の処理部分は下記になります。ほぼドキュメント通りです。

# orig_posとdist_posの型: np.float32([[int, int], [int, int], [int, int], [int, int]])
#                         np.float32(左上, 右上, 左下, 右下)
# orig_posは変換前の画像の座標
# dist_posは変換後の画像の座標
M = cv2.getPerspectiveTransform(orig_pos, dist_pos)

ret_img = cv2.warpPerspective(
    img, # opencv image (BGRA)
    M, 
    img_size, # tuple (int, int)
    flags=cv2.INTER_CUBIC,
    borderMode=cv2.BORDER_CONSTANT,
    borderValue=[255, 255, 255, 0]) 

特に難しいことはないです。ただ、最終的に画像を重ね合わせるので、変換対象の領域外については透明にしてあります。透明化するために気を付けるべきポイントは2点です。

  • cv2.warpPerspective()に渡す画像(img)はアルファチャンネル持ち(BGRA)
  • cv2.warpPerspective()boarderModeborderValueを引数に指定

背景画像と射影変換済み画像の重ね合わせ

こちらもアルファチャネルを考慮した変換を行っていますが、特に難しいことはありません。

# background画像にoverlay画像をlocation位置に張り付ける
def overlay_image(self, background, overlay, location):
    """
    backgroud: opencv image (BGRA)
    overlay: opencv image (BGRA)
    lacation: tuple (int, int)
        background position to paste overlay
    """
    def cv2pil(src):
        if src.shape[2] == 3:
            src = cv2.cvtColor(src, cv2.COLOR_BGR2RGB)
        elif src.shape[2] == 4:
            src = cv2.cvtColor(src, cv2.COLOR_BGRA2RGBA)
        src = Image.fromarray(src)
        src = src.convert('RGBA')
        return src

    pil_back = cv2pil(background)
    pil_overlay = cv2pil(overlay)
    pil_tmp = Image.new('RGBA', pil_back.size, (255, 255, 255, 0))
    pil_tmp.paste(pil_overlay, location, pil_overlay)
    result_image = Image.alpha_composite(pil_back, pil_tmp)
    return cv2.cvtColor(np.asarray(result_image), cv2.COLOR_RGBA2BGRA)

プログラム全体

プログラムの全体です。main.pypaste_image.pyを載せておきます。

from paste_image import PasteImage
import numpy as np
import cv2


def read_image(filename, resize_factor=None):
    img = cv2.imread(filename)
    if img.shape[2] != 4:
        img = cv2.cvtColor(img, cv2.COLOR_BGR2BGRA)
        img[..., 3] = 255
    if resize_factor is not None:
        height = int(img.shape[0] / resize_factor)
        width = int(img.shape[1] / resize_factor)
        img = cv2.resize(img, (width, height))
    return img


if __name__ == '__main__':
    background_file = './data/background.jpg'
    overlay_file = './data/overlay.jpg'
    back_image = read_image(background_file, resize_factor=3)
    overlay_image = read_image(overlay_file)

    pi = PasteImage()
    img = pi.get_composite_image(
        back_image, overlay_image,
        np.float32([[450, 130], [550, 114], [450, 200], [550, 193]])
    )
    cv2.imwrite('./data/output.png', img)
import cv2
import numpy as np
from PIL import Image


class PasteImage:
    def __init__(self):
        pass

    def get_composite_image(self,
                            background,
                            overlay,
                            dist_pos,
                            orig_pos=None):
        if (self.validate(dist_pos) is False):
            print('Error: dist_pos is not acceptable. return background')
            return background

        transformed = self.transform(overlay, dist_pos, orig_pos)
        minx = self.get_rectangle_left(dist_pos)
        miny = self.get_rectangle_top(dist_pos)
        img = self.overlay_image(background, transformed, (minx, miny))
        return img

    def validate(self, dist_pos):
        if (dist_pos[0][0] >= dist_pos[1][0]):
            return False
        if (dist_pos[0][1] >= dist_pos[2][1]):
            return False
        if (dist_pos[3][0] <= dist_pos[2][0]):
            return False
        if (dist_pos[3][1] <= dist_pos[1][1]):
            return False
        return True

    def overlay_image(self, background, overlay, location):
        pil_back = self.cv2pil(background)
        pil_overlay = self.cv2pil(overlay)
        pil_tmp = Image.new('RGBA', pil_back.size, (255, 255, 255, 0))
        pil_tmp.paste(pil_overlay, location, pil_overlay)
        result_image = Image.alpha_composite(pil_back, pil_tmp)
        return cv2.cvtColor(np.asarray(result_image), cv2.COLOR_RGBA2BGRA)

    def transform(self, img, dist_pos, orig_pos=None):
        orig_pos = orig_pos if orig_pos is not None else \
            self.get_corner_position(img)

        adjusted_dist_pos = self.adjust_dist_pos(img, dist_pos)
        img_size = (
            self.get_rectangle_right(adjusted_dist_pos) -
            self.get_rectangle_left(adjusted_dist_pos),
            self.get_rectangle_bottom(adjusted_dist_pos) -
            self.get_rectangle_top(adjusted_dist_pos)
        )

        M = cv2.getPerspectiveTransform(orig_pos, adjusted_dist_pos)
        ret_img = cv2.warpPerspective(
            img, M, img_size,
            flags=cv2.INTER_CUBIC,
            borderMode=cv2.BORDER_CONSTANT,
            borderValue=[255, 255, 255, 0])  # transparent
        return ret_img

    def adjust_dist_pos(self, img, dist_pos):
        minx = self.get_rectangle_left(dist_pos)
        miny = self.get_rectangle_top(dist_pos)
        new_dist_pos = np.float32(
            [[xy[0] - minx, xy[1] - miny] for xy in dist_pos])

        _, top_right, bottom_left, bottom_right = new_dist_pos
        maxx = self.get_rectangle_right(new_dist_pos)
        maxy = self.get_rectangle_bottom(new_dist_pos)
        height, width = img.shape[:2]
        ratio = min(1.0, width / maxx, height / maxy)

        if ratio != 1.0:
            new_dist_pos = np.float32([int(xy * ratio) for xy in new_dist_pos])
        return new_dist_pos

    @staticmethod
    def get_rectangle_left(pos):
        return int(min(pos[0][0], pos[2][0]))

    @staticmethod
    def get_rectangle_right(pos):
        return int(max(pos[1][0], pos[3][0]))

    @staticmethod
    def get_rectangle_top(pos):
        return int(min(pos[0][1], pos[1][1]))

    @staticmethod
    def get_rectangle_bottom(pos):
        return int(max(pos[2][1], pos[3][1]))

    @staticmethod
    def get_corner_position(img):
        height, width = img.shape[:2]
        return np.float32([[0, 0], [width, 0], [0, height], [width, height]])

    @ staticmethod
    def cv2pil(src):
        if src.shape[2] == 3:
            src = cv2.cvtColor(src, cv2.COLOR_BGR2RGB)
        elif src.shape[2] == 4:
            src = cv2.cvtColor(src, cv2.COLOR_BGRA2RGBA)
        src = Image.fromarray(src)
        src = src.convert('RGBA')
        return src

まとめ

この記事では、Pythonのopencvを利用して、射影変換した画像を別の画像に張り付けるプログラムを紹介しました。

ポイントはアルファチャンネルを指定することですが、それ以外では特に難しいことはありません。