Python でイラストの塗り残しを塗る

# はじめに

下図のようなイラストの塗り残しを塗る処理がペイントソフトでは一発で出来なさそうだったので、Python と OpenCV で実装してみます。例えばアクリルキーホルダーなどの白版を入稿する場合、塗り残しがあると品質に影響が出る危険性があるため、しっかりと処理していきます。

（この方法ですが、黒背景を後ろに追加する場合と比べて、どのような線画の色でも対応できるというメリットもあります。）

![image_paint_unpainted_area_01-original_比較.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/78152/82c13c53-99a6-39fc-427b-48e849e105a8.png)

# 入力画像

これは意図的に作成した画像ですが、線画と塗りの境界に塗り残しがあり、背景の緑色が透けてしまっていることが分かります。
（※分かりやすいように背景を緑色にしています。）

![image_paint_unpainted_area_01-original_x4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/78152/92829e40-1b6c-55fd-e053-b0619100f73a.png)

# アルゴリズムの説明

画像のアルファ値が 10以下の領域を $\boldsymbol{A}$、画像のアルファ値が 10 より大きく254以下の領域を $\boldsymbol{B}$ とします。このとき領域 $\boldsymbol{A}$ は本来透過しているべき領域であるとみなすことができます。一方、領域 $\boldsymbol{B}$ は塗り残しの可能性があります。しかし、その中で領域 $\boldsymbol{A}$ に連結している部分は、イラストの境界部分のアンチエイリアスである可能性があります。従って、領域 $\boldsymbol{A}$ に連結していない領域 $\boldsymbol{B}$ について、不透明度を 100% に変更すれば、塗り残しを塗ることができると考えます。これをプログラムにします。

![模式図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/78152/c8614c2d-5e12-7863-ee0a-652591efc01f.png)

# Python プログラム（改良前ver）

```python:paint.py
from PIL import Image
import numpy as np
import cv2

# Read PNG
img_original = np.array(Image.open('original.png')).astype(np.float32)

# Alpha <= 10 の領域、および Alpha <= 254 の領域を抽出する
img_alpha_0 = (img_original[:, :, 3] <= 10).astype(np.uint8)
img_alpha_254 = (img_original[:, :, 3] <= 254).astype(np.uint8)

Image.fromarray(255 * img_alpha_254.astype(np.uint8)).save("img_unpainted_area.png")

# 画像の連結成分をラベリングする
(_, label1) = cv2.connectedComponents(img_alpha_0, None, 4)
(_, label2) = cv2.connectedComponents(img_alpha_254, None, 4)

# 領域Aに対応する label2 のラベル番号を抽出する
img_labelselect = (label1 != 0) * label2

# ラベル番号を unique にする
uniq = np.unique(img_labelselect)
print("Selected Labels:")
print(uniq)

# 抽出したラベル番号に対応する連結領域の和領域を求める
img_magicwand = img_alpha_0 * 0
for label_id in uniq:
  if label_id == 0:
    continue
  img_magicwand = np.maximum(img_magicwand, label2 == label_id)
Image.fromarray((255 * (img_magicwand == 0)).astype(np.uint8)).save("img_magicwand.png")

# 入力画像に対し、上記で選択した領域以外のアルファ値を 255 にする
img_newalpha = (
  (img_magicwand == 0) * 255.0 +
  (img_magicwand != 0) * img_original[:, :, 3])

img_painted_rgba = np.dstack((
    img_original[:, :, 0:3],
    img_newalpha,
  ))

# Write Result
Image.fromarray(img_painted_rgba.astype(np.uint8)).save("img_fixed.png")
```

# 出力画像

## 1. 塗り残し検知

- img_unpainted_area.png
- 領域 $\boldsymbol{A} \cup \boldsymbol{B}$ に相当します

![image_paint_unpainted_area_02-unpainted-area_x4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/78152/f020ef20-0d8c-cbb4-2b18-9493e93aa427.png)

## 2. 塗り足し領域

- img_magicwand.png
- 全体集合から、領域 $\boldsymbol{A}$、および領域 $\boldsymbol{A}$ に連結している領域 $\boldsymbol{B}$ を除いた領域です

![image_paint_unpainted_area_03-magicwand_x4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/78152/bda49073-c27a-debb-7853-98c8d4fe7951.png)

## 3. 合成結果

- img_fixed.png

![image_paint_unpainted_area_04-fixed_x4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/78152/d8cc4389-6cd6-a487-3e50-364d5996c34b.png)

無事に塗り残しの部分を塗ることができました。

# 計算量について（改良後ver）

領域の連結成分が多い場合、計算量が多くなる可能性があります。そこで、連結成分が多い場合は numpy.isin() を使うように改良しましょう。

```python:paint.py
from PIL import Image
import numpy as np
import cv2

# Read PNG
img_original = np.array(Image.open('original.png')).astype(np.float32)

# Alpha <= 10 の領域、および Alpha <= 254 の領域を抽出する
img_alpha_0 = (img_original[:, :, 3] <= 10).astype(np.uint8)
img_alpha_254 = (img_original[:, :, 3] <= 254).astype(np.uint8)

Image.fromarray(255 * img_alpha_254.astype(np.uint8)).save("img_unpainted_area.png")

# 画像の連結成分をラベリングする
(_, label1) = cv2.connectedComponents(img_alpha_0, None, 4)
(_, label2) = cv2.connectedComponents(img_alpha_254, None, 4)

# 領域Aに対応する label2 のラベル番号を抽出する
img_labelselect = (label1 != 0) * label2

# ラベル番号を unique にする
uniq = np.unique(img_labelselect)
print("Selected Labels:")
print(uniq)
print(uniq.size)

# 抽出したラベル番号に対応する連結領域の和領域を求める
uniq = uniq[uniq != 0] # delete zero
img_magicwand = np.isin(label2, uniq)

Image.fromarray((255 * img_magicwand).astype(np.uint8)).save("img_magicwand.png")

# 入力画像に対し、上記で選択した領域以外のアルファ値を 255 にする
img_newalpha = (
  (img_magicwand == 0) * 255.0 +
  (img_magicwand != 0) * img_original[:, :, 3])

img_painted_rgba = np.dstack((
    img_original[:, :, 0:3],
    img_newalpha,
  ))

# Write Result
Image.fromarray(img_painted_rgba.astype(np.uint8)).save("img_fixed.png")
```

# デモサイト

上記のプログラムを使って、実際に動作を試すことができるサイトを作りました。良かったら試してみてください。

https://ackeytools.net/paint