ぷろぐらみんぐ帳

C#とかJavaScriptとか

C#でLanczos法による画像リサイズ

画像をリサイズ+αをするプログラムを作りたくなったので、Lanczos法の実装を行ってみた。

自力で実装

クォリティはさておき、とりあえず一回ぐらい自力で実装してみようということでまずは考え方から。このサイトが参考になった。

http://cafe.mis.ous.ac.jp/2015/sawasemi/画像補間法による拡大.pdf

このサイトでは拡大のみだが、拡大にしろ縮小にしろ要するにピクセル単位で整数の離散量である画素値を、連続的に取り出すのにはどうすればいいか、という問題であると勝手に脳内変換。

もう少し具体的に書くと、例えば100x1の画像を40x1に縮小する場合を考える(この場合は実質的には1次元になるので縦方向は無視)。元画像の座標をsrc(x)、縮小画像の座標をdest(x)、0≦src(x)≦99、0≦dest(x)≦39。dest(x)を基準にして対応するsrc(x)を求めると、

dest(0)=src(0), dest(1)=src(2.5), dest(2)=src(5), ……, dest(39)=src(97.5) 

元画像の2.5ピクセルや97.5ピクセルって取り出せないじゃん?どうやって取り出すの?→補間法を使う→Lanczos法はその補間法の1つと解釈するとわかりやすそう。上のサイトではニアレストネイバー法ですら、バイリニア法やLanczos法と同様の加重関数の表記に帰着させることが示されていてなるほどと思った。

突っ込みどころありありなソースだが、自分で実装してみたものがこちら。

public class LanczosCanvas : IDisposable
{
    private Bitmap srcCanvas { get; set; }

    public LanczosCanvas(Bitmap src)
    {
        srcCanvas = src;
    }

    public void Dispose()
    {
        srcCanvas.Dispose();
    }

    //重み関数
    private double WeightFunction(double distanceX, double distanceY, LanczosMethod method)
    {
        double n = method.GetDimension();
        if (n == 0.0) return 0.0;

        if (Math.Abs(distanceX) >= n || Math.Abs(distanceY) >= n) return 0.0;
        else return Sinc(distanceX) * Sinc(distanceX / n) *
                Sinc(distanceY) * Sinc(distanceY / n);
    }

    private double Sinc(double x)
    {
        if (x == 0.0) return 1.0;//これをやらないとNaNになる
        else return Math.Sin(Math.PI * x) / Math.PI / x;

    }

    //ピクセル取得
    private Color GetSrcPixel(double x, double y)
    {
        //鏡面法
        var mx = Mirror(x, srcCanvas.Width);
        var my = Mirror(y, srcCanvas.Height);
        if (mx < 0 || my < 0) throw new ArgumentOutOfRangeException("元画像の画素数が足りません");
        return srcCanvas.GetPixel(mx, my);
    }

    private int Mirror(double s, int limit)
    {
        if (s < -limit + 1) return -1;
        else if (s < 0) return (int)Math.Round(-s, 0);
        else if (s <= limit - 1) return (int)Math.Round(s, 0);
        else if (s <= 2 * limit - 2) return (int)Math.Round(2 * limit - s - 2, 0);
        else return -1;
    }

    //リサイズ
    public Bitmap Resize(int destWidth, int destHeight, LanczosMethod method)
    {
        var destCanvas = new Bitmap(destWidth, destHeight);

        var n = (int)method.GetDimension();
        var diffs = Enumerable.Range(- n + 1, 2 * n - 1).ToList();

        foreach(var i in Enumerable.Range(0, destWidth))
        {
            foreach(var j in Enumerable.Range(0, destHeight))
            {
                //ソースキャンバスでの相対座標
                var rx = (double)i / (double)destWidth * (double)srcCanvas.Width;
                var ry = (double)j / (double)destHeight * (double)srcCanvas.Height;

                //ソースから取得する座標
                var points = new List<Tuple<double, double, double, double>>();
                foreach(var p in diffs)
                {
                    var dx = p + Math.Floor(rx) - rx;
                    if (dx > n) continue;
                    foreach (var q in diffs)
                    {
                        var dy = q + Math.Floor(ry) - ry;
                        if (dy > n) continue;

                        var point = new Tuple<double, double, double, double>
                            (p + Math.Floor(rx), q + Math.Floor(ry), dx, dy);
                        points.Add(point);
                    }
                }

                //補間計算
                double desta = 0.0, destr = 0.0, destg = 0.0, destb = 0.0;
                foreach(var p in points)
                {
                    var src = GetSrcPixel(p.Item1, p.Item2);
                    var weight = WeightFunction(p.Item3, p.Item4, method);

                    desta += weight * src.A;
                    destr += weight * src.R;
                    destg += weight * src.G;
                    destb += weight * src.B;
                }

                desta = Math.Min(Math.Max(0, desta), 255);
                destr = Math.Min(Math.Max(0, destr), 255);
                destg = Math.Min(Math.Max(0, destg), 255);
                destb = Math.Min(Math.Max(0, destb), 255);

                //リサイズ先にセット
                destCanvas.SetPixel(i, j, Color.FromArgb((int)desta, (int)destr, (int)destg, (int)destb));
            }
        }

        return destCanvas;
    }
}

public enum LanczosMethod
{
    Lanczos2, Lanczos3, Lanczos4,
}

public static class Extensions
{
    public static double GetDimension(this LanczosMethod method)
    {
        switch(method)
        {
            case LanczosMethod.Lanczos2: return 2.0;
            case LanczosMethod.Lanczos3: return 3.0;
            case LanczosMethod.Lanczos4: return 4.0;
            default: return 0.0;
        }
    }
}

はっきり言って遅い。Lanczos4で1200x900の画像を縮小しようとすると20秒近くかかる。なので、これを実践的に使おうと思ったら、Sinc関数の値をキャッシュしたり(どうせ似たようなxしか放り込まれないから)、GetPixel()が重ければBitMapじゃなくてARGB値をキャッシュしたりなど工夫が必要(アンマネージ使わなくても呼び出し回数を減らすだけでそこそこ早くなるはず)。

結果はこちら。おなじみのレナさんをLanczos4で40%縮小してみた。

元画像(Wikipediaより) f:id:enjyuu:20171123210436p:plain

40%縮小 f:id:enjyuu:20171123210446p:plain

拡大してみると、帽子や背景に古い写真のような謎のざらつきが見える。(左:元画像、右:縮小画像) f:id:enjyuu:20171123210924p:plain

この原因はよくわからないが、後述のOpenCVを使った方法と比べるとこれ固有の現象であるようなので、この実装に問題があるのかと思われる。

OpenCVSharpを使った方法

よりスマートな方法として、リサイズのアルゴリズムOpenCVにまかせてしまう。C#のラッパーとしてOpenCvSharpがある(現行はOpenCvSharp3)。NuGetからインストールできる。

github.com

使い方としては、NuGetからOpenCvSharp3をインストールし、lenna.pngデバッグフォルダにコピーした上で、

//using OpenCvSharp;

using (var src = new Mat("lenna.png"))
{
    using (var dst = new Mat())
    {
        Cv2.Resize(src, dst, new OpenCvSharp.Size(205, 205), 0, 0, InterpolationFlags.Lanczos4);
        Cv2.ImWrite("lenna_opencv_small.png", dst);
    }
}

たったこれで終わり。処理時間もせいぜい数秒で、Lanczos4を使っても自前実装よりも断然早い。OpenCVで40%縮小したものがこれで、

f:id:enjyuu:20171123212028p:plain

帽子の部分を拡大して比較すると(左:自前実装による縮小、右:OpenCVによる縮小) f:id:enjyuu:20171123212541p:plain

右(OpenCV)のほうがざらつきが少なくなっている。

ライセンス等の問題がなく個人的に利用する場合、画像のリサイズはOpenCVにまかせてしまうのが良さそうだ

OpenCvSharp3の入門はこちらが詳しい。

sourcechord.hatenablog.com