HEXA BLOG

プログラム

HEXA BLOGプログラム2018.2.6

画像のリサイズを実装する(ニアレストネイバー編)

会社近くのコンビニでラーメンを食べているとき充実感があります。
ラーメン大好き某(なにがし)さんです。

ゲーム開発をする上で、1枚の画像を色んなサイズで書き出すことがあると思います。

これを手作業でやっていると、種類が増えるほど負担が無視できなくなります。
そして、元の画像に修正が発生すると、すべてリサイズし直しなので、大変なことになります。

こういった単純作業はコンピュータに任せてしまいましょう。
そうすれば時間の節約にもなるし、ヒューマンエラーも減らせます。

Photoshopならマクロを使うのも手ですが、
ここは、Unityで画像のリサイズ(拡縮)を実装していきます。

画像のリサイズ処理は、全ピクセルに対して色の補間をしてやることになります。
目的画像の1ピクセルを決めるとき、原画像から周辺のピクセルを取り出して補間します。
流れそのものは拡大でも縮小でも同じです。参照する範囲が違うだけ。

このとき使う補間手法によって、計算量や精度が大きく異なってきます。

最初は最も単純なニアレストネイバー法を例にして、リサイズの基礎を理解します。

リサイズ方法を理解するのにまず大事なことは、1ピクセルのサイズを意識することです。
これは図で見たほうが理解が早いです。

横に並んだ6ピクセルを10ピクセルに拡大するケースを想定して、各ピクセルの対応を整理してみます。

原画像sと目的画像dの間で、どのピクセルが対応するか考えるため、スケールを合わせてみました。
見ての通りそれぞれピクセル幅が異なるため、適切に座標変換を考えなくてはいけません。

ピクセルs[0]は原画像の座標系で、[0, 1)の範囲を取ります。s[0]の中心は0.5です。
同様に、s[1]は[1, 2)、末端のs[5]は[5, 6)の範囲を取ります。

目的画像に対しても同様に、ピクセルd[3]の中心は、目的画像の座標系で3.5となります。

これを原画像の座標系に変換すると、3.5 x (6 / 10) = 2.1です。これは原画像のピクセルs[2]に相当します。
つまり、d[3]に最も近い位置のピクセルはs[2]だということです。

一般化すると、d[i]にs[j]が対応するとき、スケールsとして、j = [(i + 0.5) / s]です。
[]は小数部の切り捨てを意味するガウス記号です。

実際には+0.5を省略して高速化することも多いでしょうが、
ピクセルの対応が変わってしまうのを嫌って今回は省略せずにいきます。

とりあえず、d[i]をs[j]で置き換えるような処理を全ピクセルに対して行うと単純な拡大ができそうです。

このように、原画像から最も近い位置の色で目的画像を生成する手法こそ、ニアレストネイバー法です。
ニアレストネイバー(Nearest Neighbor)とは、最近傍という意味です。

これを拡大に使うと、なんだかギザギザした画像になります。
ドット絵をぼやけさせずに拡大したいときなんかには都合がいいです。
補間によって新たな色が勝手に追加されないことが特徴といえますね。


↓弊社「魔法パスワード1111」のトルネードちゃんを1.5倍にスケール

整数倍でないとなかなかきつい仕上がりに・・・。

今度は逆に、10ピクセルを6ピクセルに縮小するケースを考えていきましょう。
先程の図の上下が反転します。

d[2]の中心は目的画像の座標系で2.5です。
これを原画像の座標系にすると、2.5 x (10 / 6) = 4.166…なので、s[4]に相当します。

d[2]をs[4]で置き換えるような操作を全ピクセルに対して行うと、
ニアレストネイバー法による縮小となります。

この手法による縮小は正直に言って、かなりイマイチです。
飛び飛びの位置のピクセルによる置き換えになるので仕方ないですね。
上の例では、s[1]、s[3]、s[6]、s[8]の情報を完全に失います。


↓弊社「魔法パスワード1111」のトルネードちゃんを0.5倍にスケール

アニメ的な塗りには大きな影響はないですが、線がきれいになりません。

ここから精度を上げていくには、
原画像から着目点の周辺ピクセルを考慮に入れる必要がありそうです。

ともかく、ニアレストネイバー法の説明は済んだので、実装例を示します。

using System;
using System.Collections.Generic;
using System.Threading;
using UnityEngine;
using UnityEngine.Assertions;

public partial class Bitmap
{
	public uint width { get { return _width; } }
	public uint height { get { return _height; } }

	public uint count { get { return _width * _height; } }

	public Color[] pixels { get { return _pixels; } }

	public Color this[int x, int y]
	{
		get { return _pixels[_width * y + x]; }
		set { _pixels[_width * y + x] = value; }
	}

	public Color this[int index]
	{
		get { return _pixels[index]; }
		set { _pixels[index] = value; }
	}

	public Bitmap(Texture2D texture)
		: this(texture.GetPixels(), (uint)texture.width, (uint)texture.height)
	{
	}

	public Bitmap(uint width, uint height)
		: this(new Color[width * height], width, height)
	{
	}

	public Bitmap(Color[] pixels, uint width, uint height)
	{
		Assert.IsTrue(pixels.Length == width * height);

		_width = width;
		_height = height;
		_pixels = pixels;
	}

	public Bitmap copy()
	{
		var bitmap = new Bitmap(_width, _height);
		_pixels.CopyTo(bitmap._pixels, 0);
		return bitmap;
	}

	public Texture2D createTexture()
	{
		var texture = new Texture2D((int)_width, (int)_height, TextureFormat.RGBA32, false);
		texture.SetPixels(_pixels);
		texture.Apply();
		return texture;
	}

	public Color get(int x, int y)
	{
		x = clamp(x, 0, (int)_width - 1);
		y = clamp(y, 0, (int)_height - 1);
		return this[x, y];
	}

	private Bitmap resizeNearestNeighbor(uint width, uint height)
	{
		var bitmap = new Bitmap(width, height);

		// スケール
		var scaleX = (float)width / _width;
		var scaleY = (float)height / _height;

		// 単位を原画像の座標系に変換
		var unitX = 1.0f / scaleX;
		var unitY = 1.0f / scaleY;

		// 1ピクセル幅を考慮した座標の補正
		var correctionX = unitX * 0.5f;
		var correctionY = unitY * 0.5f;

		parallelFor(0, (int)bitmap._height, y => {
			for(int x = 0; x  max) return max;
		if(value < min) return min;
		return value;
	}

	private static void parallelFor(int start, int end, Action action)
	{
		var length = Math.Abs(end - start);
		if(length == 0) return;

		// start  end) {
			var tmp = start;
			start = end;
			end = tmp;
		}

		extendHandlePoolCapacity(length);

		// 並列タスクの生成と同期オブジェクトの取得
		var index = 0;
		var handles = new ManualResetEvent[length];
		for(int i = start; i  {
				action(arg);
				handle.Set();
			});

			handles[index++] = handle;
		}

		// 並列タスクの終了待ち
		foreach(var handle in handles) {
			handle.WaitOne();
			pushHandle(handle);
		}
	}

	private static void extendHandlePoolCapacity(int capacity)
	{
		if(_handlePool.Capacity < capacity) {
			_handlePool.Capacity = capacity;
		}
	}

	private static void pushHandle(ManualResetEvent handle)
	{
		_handlePool.Add(handle);
	}

	private static ManualResetEvent popHandle()
	{
		if(_handlePool.Count <= 0) {
			return new ManualResetEvent(false);
		}

		var lastIndex = _handlePool.Count - 1;
		var handle = _handlePool[lastIndex];
		_handlePool.RemoveAt(lastIndex);
		handle.Reset();
		return handle;
	}

	private static readonly List _handlePool = new List();

	private uint _width = 0u;
	private uint _height = 0u;

	private Color[] _pixels = null;
}

画像の入力については、読み取り許可されたTexture2Dからピクセルデータを取得できます。
取得したピクセルデータは、画像の左下を原点とした順序で得られることに注意です。
Texture2DからPNG/JPG変換が可能なので、これをファイルに保存すれば、出力も完了です。

このリサイズ処理は処理順に依存がないので、並列化により高速化しています。
Unity 5.6系までの.NET Frameworkが古くてParallel.Forが使えなかったので、
仕方なく自前で用意したのがparallelForメソッドです。

今回は画像処理部分を自前で実装しましたが、
ImageMagickなどのツールを実行するフロントエンドを作るのも当然アリです。

ただ、自分のケースでは、

 ・デザイナーPCに特殊な実行環境を入れさせたくなかった
 ・UnityのTexture2DならPSDファイルも入力ファイルとして扱える
 ・独自実装なら融通がきくので、他の処理との組み合わせがしやすい

上記の理由により、エディタ拡張から利用できるようにするのが使いやすいかな、と思いました。

次回からもっと精度の良い手法に乗り換えていきます。

RECRUIT

大阪・東京共にスタッフを募集しています。
特にキャリア採用のプログラマー・アーティストに興味がある方は下のボタンをクリックしてください

RECRUIT SITE