見出し画像

パルデアの思い出をモザイクアートにした実装と解説(Python)

 2023年9月。ポケモン公式から「ポケモンSVのあなたのレポートを作って提出しよう」という企画が発表され、そこにパルデアを旅する中で撮り溜めたスクショ12100枚から作ったモザイクアートを添えたレポートを提出しました。

 このモザイクアートを作るにあたって、そのモザイクアートの並べ方の条件をカスタムしたくなったので、「モザイクアートを作るプログラムの実装」から作りました。

 せっかく作ったものなので、そのモザイクアートの実装(python)と解説を残しておきます。今後、自前でモザイクアートの実装を作りたい方やカスタムをしたい方の一助になれば、幸いです。


実装の方針

・今回の用途と目的

 今回のモザイクアートを作るプログラムは、先述のポケモンSVのレポートを提出にあたって、その中に掲載する「パルデアの旅で撮り溜めたスクショを一纏めにしたモザイクアート」「並べ方の条件をカスタムする」ために自作したものです。

・モザイクアート実装を作るにあたっての条件

 今回のモザイクアートを作るにあたって、大事にしたいことがありました。それは、「旅の思い出を一つにする」ということ。これをなるべく表現できるような形式で、モザイクアートを作成するということが前提となっていました。

 その大事したいことを実現するために、自作の実装には次の条件を用意することにしました。

 まず、「並べる画像に同じ画像は利用しない」という条件。たくさんある旅の思い出のスクショを使う際に、重複を許してまでモザイクアートの見た目をよくすることよりも、種類を多く利用することを最優先しました。
 続いて、「並べる画像と並べたい場所の比較方法はカスタムできるようにする」「並べる画像の枚数はカスタムできるようにする」という条件。これは、並べる画像の種類を多くすることを優先したので、その中で見た目を調整できるようにするためです。またカスタムする中で、計算量がかかるような手法でも、実行時間が許容できるもの(レポートの制作が締め切りに間に合うような所要時間)であれば構わず採用する方針で実装しています。


実装について

・大まかな流れ

 今回実装したモザイクアートを作るプログラムは、大まかに以下の流れで処理がなされるように作成しました。

・モザイクアート実装の大まかな流れ
 (1) 並べる画像を読み込み
 (2) 目的の画像を読み込み
 (3) 目的の画像を分割
 (4) 分割した画像のピースをシャッフル
 (5) ピースごとに並べる画像と比較して対応する画像を決定
 (6) シャッフル順を戻す
 (7) くっつけて一つのモザイクアートにする

 今回の自作実装にあたって、以下の先人のモザイクアート実装を参考にしました。

 そして、自作実装に求める条件のために、実装をカスタムしています。「並べる画像に同じ画像は利用しない」の条件のために(4)(6)の工程で工夫を、「並べる画像と並べたい場所の比較方法はカスタムできるようにする」「並べる画像の枚数はカスタムできるようにする」の条件は(5)の工程をカスタムできるようにしました。

・実装全体像

 まず、実装の全体像を掲載します。実装の解説については、その後に掲載します。

実装に関する注意
・言語はPythonです
・自分が作りたいモザイクアートが作れれば問題なかったため、一部対応していないケースや実装が甘いところがありますので、気になる方は自身に合うようにカスタムしてください

import os
import numpy as np
import random
import cv2
from IPython.display import display, Image
from tqdm import tqdm


# 利用する画像サイズは以下のものとする
HEIGHT = 720
WIDTH = 1280


# 画像を調整しながら読み込み
def load_images(image_path, scale=1.0):
    height = int(HEIGHT * scale)
    width = int(WIDTH * scale)
    
    image = cv2.imread(image_path)

    # 画像の向きを整える
    if image.shape[0]  > image.shape[1]:
        image = cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE)
    
    # リサイズする
    image = cv2.resize(image, (width, height), interpolation=cv2.INTER_AREA)
    return image    


# 画像を分割する
def divide_image(img, n_row=4, n_col=4):
    height, width, _ = np.shape(img)
    divide_list = [[img[int(row_ind * (height / n_row)) : int((row_ind + 1) * (height /n_row)), 
                                    int(col_ind *(width / n_col)) : int((col_ind + 1) * (width  / n_col)), :]
                            for col_ind in range(n_col)] for row_ind in range(n_row)]
    return divide_list


# 2つの画像の間のスコアを計算
def diff_score(img1, img2):
    if (img1 is None) or (img2 is None):
        return np.inf
    
    diff_score = np.sum(np.sqrt(np.sum((img1 - img2)**2, axis=2)))
    return diff_score


# 2つの画像の間のスコアを計算(Hueの計算用)
def diff_score_hsv(img1, img2, color_coef=np.array([1, 1, 1])):
    if (img1 is None) or (img2 is None):
        return np.inf
    
    #Hueに関する誤差の計算
    max_hue = 180 * color_coef[0]
    diff_hue = np.abs(img1[:,:,0] - img2[:,:,0])
    diff_hue[diff_hue > (max_hue * 0.5)] = max_hue - diff_hue[diff_hue > (max_hue * 0.5)]

    #他の部分の誤差とあわせる
    diff_score = np.sum(np.sqrt(np.sum((np.dstack([diff_hue, img1[:,:,1:] - img2[:,:,1:]]))**2, axis=2)))
    return diff_score


# 画像を調整しながら表示
def show_img(img, scale=1):
    img = cv2.resize(img, None, fx=scale, fy=scale)
    _, buf = cv2.imencode(".jpg", img)
    display(Image(data=buf.tobytes()))


# 画像群をくっつけて表示
def concat_tile_image(image_list, n_row=10, n_col=10, scale=1.0):
    height = int(HEIGHT * scale)
    width = int(WIDTH * scale)
    concat_list = []

    for idx, image in enumerate(image_list):
        if idx % n_col == 0:
            row_list = [image]
        else:
            row_list = row_list + [image]
            if idx % n_col == (n_col -1):
                row_img = np.hstack(row_list)
                concat_list = concat_list +[row_img]
        if idx == len(image_list) - 1:
            if idx % n_col < n_col -1:
                n_blank = n_col - (len(image_list) % n_col)
                row_list = row_list + [np.zeros( (width, height, 3) )] * n_blank
                row_img = np.hstack(row_list)
                concat_list = concat_list + [row_img]            
        if idx >= n_row * n_col:
            break
    concat_img = np.vstack(concat_list)

    # 画像を表示
    # お好みで画像を保存するものに変更可能
    show_img(concat_img)


# モザイクアートを作る
def mosaic(input_dir, target_image_path, mode="RGB", n_div=110, piece_scale=1/40):
    n_row = n_div
    n_col = n_row
    eval_height, eval_width = 9, 16
    
    ### (工程1) 並べる画像を読み込み
    # モザイクアートに使う写真をリサイズして読み込み
    image_names = sorted([f for f in os.listdir(input_dir) if f.endswith((".png", ".jpg"))])
    source_piece_list = [load_images(
            os.path.join(input_dir, p),
            scale=piece_scale
        ) for p in tqdm(image_names, desc="[Load source images]")]
    
    # 比較用にリサイズしたものも準備
    source_eval_piece_list = [cv2.resize(
            img, (eval_width, eval_height), interpolation=cv2.INTER_AREA
        ) for img in tqdm(source_piece_list,desc="[Resize source images]")]
    
    ### (工程2) 目的の画像を読み込み
    target_image = cv2.imread(target_image_path)

    ### (工程3) 目的の画像を分割
    target_piece_list = divide_image(target_image, n_col=n_col, n_row=n_row)
    
    ### (工程4) 分割した画像のピースをシャッフル
    # ピースをランダムな順番に並べ替える(後から復元可能にする)
    tile_ind_list = [(r_ind, c_ind) for c_ind in range(n_col) for r_ind in range(n_row)]
    tile_ind_list = random.sample(tile_ind_list, k=len(tile_ind_list))
    rand_target_piece_list = [target_piece_list[ind[0]][ind[1]] for ind in tile_ind_list]

    # 比較用にピースをリサイズ
    rand_target_piece_list = [cv2.resize(
            img, (eval_width, eval_height), interpolation=cv2.INTER_AREA
        ) for img in tqdm(rand_target_piece_list, desc="[Resize target pieces]")]
    
    ### (工程5) ピースごとに並べる画像と比較して採用画像を決定
    # 比較モードごとに色空間を変換
    # 係数はお好みで調整
    if mode == "HSV":
        color_mode = cv2.COLOR_BGR2HSV
        color_coef = np.array([1, 1, 1])
    elif mode == "Hue":
        color_mode = cv2.COLOR_BGR2HSV
        color_coef = np.array([4, 1, 1])
    elif mode == "LAB":
        color_mode = cv2.COLOR_BGR2LAB
        color_coef = np.array([1, 1, 1])
    elif mode == "LAB2":
        color_mode = cv2.COLOR_BGR2LAB
        color_coef = np.array([2, 1, 1])
    elif mode == "LAB4":
        color_mode = cv2.COLOR_BGR2LAB
        color_coef = np.array([4, 1, 1])
    
    if not mode == "RGB":
        source_eval_piece_list = [cv2.cvtColor(img, color_mode) * color_coef
                                  for img in source_eval_piece_list]
        rand_target_piece_list = [cv2.cvtColor(img, color_mode) * color_coef
                                  for img in rand_target_piece_list]

    # ピースごとにスコアを比較
    mosaic_list = []
    for target_piece in tqdm(rand_target_piece_list, desc="[Score matching]"):
        if mode in ["HSV", "Hue"]:
            score = np.array([diff_score_hsv(target_piece, s, color_coef=color_coef) for s in source_eval_piece_list])
        else:
            score = np.array([diff_score(target_piece, s) for s in source_eval_piece_list])
        min_ind = np.argmin(score)
    
        # 採用画像をモザイクアートリストに加えて、元のプールから削除
        mosaic_list += [source_piece_list[min_ind]]
        source_eval_piece_list[min_ind] = None
    
    ### (工程6) シャッフル順を戻す
    # モザイクアートリストをピースをランダムに並べ替えた時の元の順番に並べ替える
    tile_argind_list = np.argsort([ind[0] * n_col + ind[1] for ind in tile_ind_list])
    mosaic_list_sorted = [mosaic_list[ind] for ind in tile_argind_list]
    
    ### (工程7) くっつけて一つのモザイクアートにする
    # 並べ替えたピースを一つの画像にくっつけて表示
    # お好みで画像を保存するものに変更可能
    concat_tile_image(mosaic_list_sorted, n_col=n_col, n_row=n_row, scale=piece_scale)


if __name__ == '__main__':
    input_dir = "" ### モザイクアートに使う写真が格納されているディレクトリを指定
    target_image_path = "" ### モザイクアートで作りたい画像のパスを指定

    mode = "LAB2" ### 利用する比較モードを指定
    n_div = 110 ### 作りたい画像の各辺を何分割するかを指定(n_div * n_div枚をモザイクアートで使うことになる)
    piece_scale = 1 / 40 ### モザイクアートに並べる画像のサイズの倍率を指定

    mosaic(input_dir, target_image_path, mode=mode, n_div=n_div, piece_scale=piece_scale)

 もしこの実装を利用する場合は、使う画像類のパスを埋めた上で、パラメータ類や画像比較の計算方法をお好みでカスタムして利用してください。


・実装解説

 一部の工程を、その工程のやりたいことやなぜその方法にしたのかを、かいつまんで解説します。

■「並べる画像に同じ画像は利用しない」の条件のための機構について
 今回の実装では、並べる画像に同じ画像は利用しないという条件を用意していました。そのために、工程4及び工程6で並べる画像をどこから選択するかという順番をシャッフルする機構を取り入れました。
 モザイクアートでは、並べる画像が並べたい場所に近い画像を選択できると、見た目がよくなると考えられます。重複を許さない時、どのように並べる場所を選択するかの方針として、まず大まかに2方向を考えました。

・方針1 : 全体の見た目重視
 全ての並べる画像と全ての配置場所のピースの誤差を計算して、その総和が最小になる配置を求める
・方針2 : 実装難易度重視
 配置場所のピースを順番に選んでいき、その時点で残っている並べる画像の中で誤差最小のものを選択していく

 今回は実装難易度を重視して、順番に選択する方を採用しました。ただ、この方針の場合、後半に配置を行う場所は残り物から選ばないといけなくなってしまうため、ある方向から順番に並べていくと、見た目のムラが発生する恐れがありました。その対応策として、配置を行う場所をランダムな順番に選ぶことで、見た目のムラを全体に散らすようにしました。

■並べる画像の並べ方のカスタムについて
 画像重複なしを優先した上での見た目の調整として、主に工程5の部分を中心にカスタムできる要素を用意しました。今回主に調整を行った要素は、「並べる画像の枚数」と「画像を比較する方法」です。
 利用する並べる画像として、用意した画像のほとんど全てを使おうとすると、どうしてもどこに配置するにも向かない画像が一定量含まれる可能性があります。そのため、ちょうどいい枚数を探ることで見た目が綺麗になる可能性があると考えました。
 実際枚数を変更してモザイクアートを作成したところ、見た目の綺麗さに変化があることを確認しました。ただ今回は、「旅の思い出を一つにする」という前提を優先して、結局のところ用意したスクショをなるべくほとんど使えるような枚数設定に落ち着きました。

 もう一点、並べる画像と配置するピースをどう比較するかという点については、実際どうなるんだろうという興味もあったので、変更してカスタムできるようにしました。
 今回の実装では、比較したい画像を双方ある程度の大きさ(9x16)に縮小して、そのサイズでピクセルごとの色の距離を計算しその合計を誤差として扱うことにしました。そして、「色をどう表すか」の部分をカスタムできるようにして、「どんな画像を近いとみなすか」を変更できるようにしました。
 今回のモザイクアート作成では、RGB・HSV・LABの色の表示を係数をつけて実行できるようにしました。用意した画像の場合では、RGBがいまいち上手くいかず、HSVがある程度形が見えるものができ、LABが一番見た目が整ったものができました。そのLABの中でLの係数を2倍にしたものを今回は採用しました。

RGBは上手くいかなかった
HSVは色相よりも明度彩度が優先されているような配置をしていたので、Hueの係数を4倍にしたところやや赤みが強くなった
LABは比較的整っており、Lの係数を2倍4倍とすることでやや赤っぽかったものを抑えることができたが、4倍にすると色味が合わない配置が目立つようになったので、2倍を採用


おわりに

 今回モザイクアートの実装を自作してみたら、並べ方の条件・利用する枚数・色の表し方などで、出来上がったモザイクアートの見た目にこれほどの差が出るということを体験しました。本来の大きな目的としては、企画のためにモザイクアートが欲しかっただけだったので、この体験は良い副産物となりました。
 もし今後この実装が、モザイクアートの実装を自作したい方やカスタムをしたい方の助けになればうれしいです。


▼ 作成したモザイクアートの利用先


この記事が参加している募集

この記事が気に入ったらサポートをしてみませんか?