白い冷蔵庫は、白い背景から切り抜けない — マゼンタ背景と色かぶりの取り方

白物戦記の主役は冷蔵庫や洗濯機です。白い機体を白い背景から切り抜くと、機体まで透けてしまいます。そこで生成する絵の背景をマゼンタ一色にしました。背景の抜き方、機体の縁に残る紫色のにじみの取り方、縮めたときの白フチ、そして配布物に原寸PNGが9.8MB残っていた話まで、コードつきで書きます。

白いものを、白から切り抜く

白物戦記のタイトル画面。白い冷蔵庫の機体が中央に立つ
主役は白い冷蔵庫。背景から切り抜くとき、いちばん困る色です。

白物戦記は、冷蔵庫・洗濯機・掃除機が食材を護衛する戦術ゲームです。機体の絵は生成した原画から起こしています。

生成した絵を盤面に置くには、背景を透明にして機体だけを切り抜く必要があります。ここで、白い機体ならではの問題が起きました。主役は白物家電、つまり白や銀色です。背景を白や灰色にすると、機体の白い胴と背景の白を区別できません。背景だけを消すつもりが、冷蔵庫の扉まで透けてしまいます。

そこで、生成する絵の背景はマゼンタ(#FF00FF)一色で塗らせることにしました。マゼンタは赤と青が最大で、緑だけがゼロの色です。白や灰色の家電には、この「緑だけが極端に低い」色がまずありません。機体と背景を、色の性質で分けられます。

背景を抜き、紫かぶりを取る

切り抜きは二段です。まず、マゼンタに十分近い画素を透明にします。次に、機体の縁に残った「紫色のにじみ」を取ります。背景の色が反射や縁のにじみとして機体に移り、白い胴の輪郭がうっすら紫になる現象です。

紫色のにじみを取るには、マゼンタの性質をそのまま使います。マゼンタが混ざった画素は、緑が、赤と青の低いほうより下がっています。だから緑をそこまで戻せば、紫だけが消えます。

PINK = (255, 0, 255)

def unpink(arr, tol=90):
    rgb = arr[:, :, :3].astype(np.int16)
    out = arr.copy()
    # 1. マゼンタとの差が小さい画素を透明にする
    out[:, :, 3] = np.where(np.abs(rgb - np.array(PINK)).sum(axis=2) < tol, 0, arr[:, :, 3])
    # 2. 緑を「赤と青の低いほう」まで戻す。紫色のにじみを減らせる
    floor = np.minimum(rgb[:, :, 0], rgb[:, :, 2])
    out[:, :, 1] = np.maximum(rgb[:, :, 1], floor).astype(np.uint8)
    return out

この処理がうまいのは、赤や青の画素を変えずに済むところです。実際にいくつかの色を通すと、こうなります。

画素通す前通した後
白い胴に紫がかぶった縁(250, 200, 250)(250, 250, 250) 白に戻る
赤い炎(255, 80, 30)変わらない(青が低いので下限も低い)
青い差し色(40, 90, 220)変わらない(赤が低いので下限も低い)
紫の塗装(150, 60, 170)(150, 150, 170) 灰色へ寄る

最後の行が、この方法の限界です。機体に本当に塗られた紫も、背景の色移りと区別できずに灰色へ寄ります。マゼンタを背景に選ぶと、紫は機体に使いにくい色になります。

縮めたら、白いフチが出る

切り抜いた絵は、ゲーム画面のマス目に合う大きさへ縮めます。ここにも落とし穴があります。

上の処理を通すと、透明になった背景の画素は、色だけ見ると白かそれに近い色になっています。緑を赤と青の低いほうまで戻したので、マゼンタの (255, 0, 255) は (255, 255, 255) になるのです。透明なので普段は見えません。ところが普通に縮小すると、隣り合う画素の色が平均されるので、透明な画素に残った白が機体の縁に混ざり、輪郭に白いフチが出ます。

防ぐには、縮める前に色へ透明度を掛けておきます。乗算済みアルファと呼ばれる形です。透明な画素は色も0になるので、平均しても縁に何も混ざりません。

def premul_resize(rgba, scale):
    im = Image.fromarray(rgba, 'RGBA')
    size = (max(1, round(im.width * scale)), max(1, round(im.height * scale)))
    # 'RGBa' は色に透明度を掛けた形。この形で縮めてから元へ戻す
    return np.array(im.convert('RGBa').resize(size, Image.LANCZOS).convert('RGBA'))

背景の抜き方と縮め方は、別々の処理に見えて、こうしてつながっています。前の段が「見えない色」をどう残したかで、後の段の正しいやり方が変わります。

銃口の炎が、隣の機体につながる

生成した絵は、一枚に何体も並んでいます。背景を抜いたあと、つながった画素のかたまりを一体ずつ切り出します。

ところが射撃の姿勢では、銃口の炎が隣の機体にまで届き、2体が一つのかたまりになります。そこで、かたまりの幅が並の機体の1.4倍を超えたら、中央寄りで縦方向の画素がいちばん少ない列を境に二つへ割ります。炎のような細い部分がつなぎ目になっているので、いちばん細いところで切れば、たいてい2体の間に当たります。

二度抜くと、紫の模様が消える

切り抜きは、配布前の素材制作の段階で済ませています。白物戦記が配布用に置いている画像(PNGとWebPの114枚)を調べると、マゼンタの背景を残したものは1枚もありません。

それでも、ゲームが画像を読み込む処理には、もう一段の安全網を入れています。透明な画素を一つも持たない絵が来たときだけ、読み込んだ時点でマゼンタを抜くのです。縁の画素は、見えている色を「機体の色とマゼンタが、ある割合で混ざったもの」とみなし、合成の式を逆に解いて機体の色を戻します。

// 見えている色 C = a × 機体の色 F + (1 − a) × マゼンタ M を、F について解く
const excess = Math.min(r, b) - g;          // 赤と青が緑をどれだけ上回るか
const a = 1 - excess / 255;                 // 混ざったマゼンタの割合から、機体の不透明度を見積もる
const F = [(r - 255 * (1 - a)) / a, g / a, (b - 255 * (1 - a)) / a];
// 例:白い機体(240,240,240)にマゼンタが4割混ざった(246,144,246) → (240,240,240) に戻る
// 実際には、はっきりマゼンタの画素に接した縁の画素だけに掛ける

この安全網には、掛けてはいけない相手がいます。すでに切り抜き済みの絵です。切り抜き済みの絵にもう一度掛けると、機体に描かれた紫がかった模様、たとえば (200, 60, 210) のような色が「背景のマゼンタ」と判定されて穴になり、形まで変わってしまいます。そのため、透明な画素を一つでも持つ絵は、仕上がった切り抜きとみなして手を出さない決まりにしています。色を抜く処理は、一枚の絵に一度だけ。これが安全網を安全に保つ条件でした。

配布物に、原寸PNGが9.8MB残っていた

最後に、配布用の画像を作る工程でも問題が見つかりました。配信量を調べていたとき、白物戦記を一度開くだけで21.2MBを読み込んでいると分かったのです。そのうち9.8MBは、原寸のままのPNG9枚でした。

本来、配布物を作る段で、画像は実際に描く大きさへ縮め、WebPへ詰め直し、中身から決まる名前に付け替えています。名前を変えるので、その画像を参照している箇所もすべて書き換えます。書き換えられない参照が一つでも見つかった画像は、参照を壊さないために、原寸のまま残します。

9枚が残った原因は、テストでしか使わない寸法を記録した表でした。原寸を記録した表が "keyart.png" のような素のファイル名を見出しにしていたため、配布の処理は「どこかでこの名前が使われているが、書き換え方が分からない」と判断し、変換せず原寸で残していたのです。

直し方は、作品の設定で「この9枚は、実行時には固定のパスから読むだけなので、変換してよい」と明示することにしました。ここでも一度つまずいています。最初はもっと広く挙げたところ、実行時に './assets/drones/' + 種類 + '-kaden.png' のように名前を組み立てて読む絵まで含めてしまい、確認で404が出ました。名前を組み立てて読む絵は、変換すると見つからなくなります。挙げてよいのは、パスを文字列として直接指定している画像だけです。

結果、白物戦記が配る画像の合計は73.0MBから16.6MBになり、一度開いたときの読み込みは21.2MBから12.7MBになりました。

差し替えのときに確かめること

ブラウザで画素を調べるなら、Canvasの getImageData が使えます。ただし別の配信元から読み込んだ画像は、CORSの条件によって読み出せないことがあります(MDNのgetImageDataの解説)。一コマずつの位置のずれを直す話は、からくり双星の位置合わせの記事に書きました。