今回は小物ネタです。
- 2020/10/22 更新: アルファブレンドを実装し、グリッド線の不透明度を設定できるようになりました。
概要
以下のような、デッサンスケールと呼ばれる道具をご存知でしょうか。

実物の観察に基づく素描の際に、構図を検討したり、全体的な比率を把握するために用いられる道具です。詳しくは出典元URLの記事を読んでいただければと思います。
このデッサンスケールは、本来的には実物にかざして使うものですが、デッサンスケールを用いて行われる「まず画面をグリッドに区切り、大まかな比率を把握する」という作業は、写真や絵の模写練習についても有用であると考えられます。また、現状、クリスタ や Procreate など一般に利用可能なお絵かきツールには、ガイドとして任意のグリッドを表示できる機能が備わっています。しかし、いずれのツールを用いても、「お手本の画像と同サイズのキャンバスがあり、両者に同じグリッドが引かれている」という状態にするのはやや手間がかかります。
そこで今回は次のようなプログラムを作成しました。それは、以下のようなお手本画像が与えられたとき、

「その隣に同サイズの空白領域(以下『キャンバス』)を持ち、同様のグリッドが引かれた新たな画像」を出力するというものです。

この画像をお絵描きソフトで上のレイヤーに配置することで、グリッドを参考にしながら練習を行うことができます。
実装
コードは以下の通りです。
import cv2 import numpy as np import argparse import os def get_args(): parser = argparse.ArgumentParser() parser.add_argument('path', type=str) parser.add_argument('--dst_dir', type=str, default='./') parser.add_argument('--div', type=int, default=4) parser.add_argument('--alpha', type=float, default=0.2) parser.add_argument('--side', type=str, default='left') parser.add_argument('--prefix', type=str, default='') parser.add_argument('--postfix', type=str, default='_dessin_scale') args = parser.parse_args() return args def imread_bgra(args): img = cv2.imread(args.path, flags=cv2.IMREAD_UNCHANGED) h, w, c = img.shape if c == 3: alpha = np.ones((h, w, 1), dtype='uint8') * 255 img = np.dstack((img, alpha)) return img def draw_grid(img, canvas, args): h, w, c = img.shape alpha_uint8 = round(255 * args.alpha) for i in range(args.div - 1): # 2の倍数で分割する際, グリッド線に強弱をつける lw = (i % 2 + 1) if args.div % 2 == 0 else 1 # draw horizontal line y_i = round((i + 1) * h / args.div) s_h = (0, y_i) t_h = (w, y_i) cv2.line(canvas, s_h, t_h, (0, 0, 255, alpha_uint8), lw) # draw vertical line x_i = round((i + 1) * w / args.div) s_v = (x_i, 0) t_v = (x_i, h) cv2.line(canvas, s_v, t_v, (0, 0, 255, alpha_uint8), lw) c_alpha = canvas[..., 3][..., np.newaxis] / 255. img = canvas * c_alpha + img * (1 - c_alpha) return img, canvas def concat_canvas(img, canvas, args): if args.side == 'left': res = np.hstack((canvas, img)) elif args.side == 'right': res = np.hstack((img, canvas)) elif args.side == 'above': res = np.vstack((canvas, img)) elif args.side == 'below': res = np.vstack((img, canvas)) else: raise NotImplementedError return res def save(res, args): try: os.makedirs(args.dst_dir) except OSError: pass res_name = f'{args.prefix}{os.path.basename(args.path)}{args.postfix}.png' cv2.imwrite(os.path.join(args.dst_dir, res_name), res) def main(args): img = imread_bgra(args) canvas = np.zeros_like(img) img, canvas = draw_grid(img, canvas, args) res = concat_canvas(img, canvas, args) save(res, args) if __name__ == "__main__": args = get_args() main(args)
プログラムが受け取る引数は次の通りです。
path: 入力する画像へのパス。--dst_dir: 結果の画像を出力するディレクトリ。デフォルトは./なので、プログラムが存在する場所にそのまま吐き出す。--div: 画像を何分割するか。デフォルトは4。--side: お手本画像のどちら側にキャンバスを配置するか。デフォルトはleftなので、左側に置かれる(私が左利きなのでこうしています)。--alpha: グリッド線の不透明度。デフォルトは0.5。--prefix: 出力画像名の先頭に付ける文字列。デフォルトはなし。--postfix: 出力画像名の末尾に付ける文字列。デフォルトは_dessin_scaleとなっており、NAME_dessin_scale.pngのような画像が出力される。
実行例は次の通り。
$ python main.py images/ref.png --dst_dir=result/ --div=6
まとめ
デッサンスケールの発想で、絵の模写練習用の画像を作成するプログラムを書きました。改善案としては、画像の分割数を縦横それぞれ指定できるようにするとか、グリッドの色指定まわりとかでしょうか。あるいは、ドラッグ&ドロップで生成できるようにするとか、グリッドを別画像に出したりもしたい気もしました。