等間隔に並んだ画像データをグリッド毎にトリミングする

例えばスプライトアニメーションで等間隔に並んでいるデータの余白が大きすぎる場合、各画像の余白をトリムしたい場合がありますが、1枚の画像になっている場合トリミングするのはなかなか大変です。今回はそういった画像を

実行例

分かりやすいように極端に削っていますが、例えば以下のような4列4行な画像の各グリッドのの上下左右を20pxずつ削って

f:id:Takachan:20211009191502p:plain

以下のように変換して保存しなおします。

f:id:Takachan:20211009191726p:plain

実装・確認環境

この記事は以下の環境で作成及び動作確認を行っています。

  • ViauslStudio 2019(16.11.4)
  • .NET 5 + C# 9.0
  • Windows 10

以下の記事に従ってプロジェクトをセットアップ済み

takap-tech.com

コンソールだけど System.Drawing 使ってるせいで Windows のみという…w

実装コード

GridImageTrimParamクラス

処理を行うときに与えるパラメータークラスです。

/// <summary>
/// グリッド状の画像をトリムするためのパラメーターを表します。
/// </summary>
public class GridImageTrimParam
{
    #region 見本...
    //
    // 以下のような JSON ファイルを
    // JsonSerializer でデシリアライズできるように構成している
    // {
    //   "rows": 4,
    //   "columns": 4,
    //   "margin": {
    //     "left": 20,
    //     "top": 20,
    //     "right": 20,
    //     "bottom": 20
    //   },
    //   "padding_px": 1,
    //   "make_backup": true,
    //   "optimize_output": false
    // }
    //
    #endregion

    /// <summary>
    /// グリッドの列数を取得します。
    /// </summary>
    /// <remarks>よこ!</remarks>
    [JsonPropertyName("rows")]
    public int Rows { get; }

    /// <summary>
    /// グリッドの行数を取得します。
    /// </summary>
    /// <remarks>たて!</remarks>
    [JsonPropertyName("columns")]
    public int Columns { get; }

    /// <summary>
    /// 画像の余白を取得します。
    /// </summary>
    [JsonPropertyName("margin")]
    public ThicknessInt Margin { get; }

    /// <summary>
    /// グリッドとグリッドの間の上下左右に均等な余白を表します。既定値は 1px です。
    /// </summary>
    [JsonPropertyName("padding_px")]
    public int Padding { get; set; } = 1;

    /// <summary>
    /// 出力先に既に画像が存在した場合
    /// 既存のファイルをバックアップするかどうかのフラグを設定または取得します。
    /// <para>true: バックアップする(既定値) / false: バックアップしない</para>
    /// </summary>
    [JsonPropertyName("make_backup")]
    public bool MakeBackup { get; set; } = true;

    /// <summary>
    /// 出力結果を最適化するかどうかのフラグを設定または取得します。
    /// <para>true: 実行する / false: 実行しない</para>
    /// </summary>
    /// <remarks>
    /// lib の下に pngout.exe が配置されている事を想定。
    /// </remarks>
    [JsonPropertyName("optimize_output")]
    public bool OptimizeOutput { get; set; }

    /// <summary>
    /// 指定したパラメーターでオブジェクトを作成します。
    /// </summary>
    public GridImageTrimParam(int rows, int columns, ThicknessInt margin)
    {
        this.Rows = rows;
        this.Columns = columns;
        this.Margin = margin;
    }

    /// <summary>
    /// 外部の JSON ファイルを読み込んで <see cref="GridImageTrimParam"/> オブジェクトを取得します。
    /// </summary>
    public static GridImageTrimParam LoadJson(string filePath)
    {
        string jsonStr = File.ReadAllText(filePath);
        return JsonSerializer.Deserialize<GridImageTrimParam>(jsonStr);
    }
}

#### ThicknessIntクラス

GridImageTrimParam が使用する上下左右の余白の大きさを表すクラスです。

/// <summary>
/// 上下左右の4つの数値の組み合わせを表します。
/// </summary>
public class ThicknessInt
{
    [JsonPropertyName("left")]
    public int L { get; }
    
    [JsonPropertyName("top")]
    public int T { get; }
    
    [JsonPropertyName("right")]
    public int R { get; }
    
    [JsonPropertyName("bottom")]
    public int B { get; }

    /// <summary>
    /// 四隅のサイズを指定してオブジェクトを作成します。
    /// </summary>
    public ThicknessInt(int l, int t, int r, int b)
    {
        this.L = l;
        this.T = t;
        this.R = r;
        this.B = b;
    }
}

ごちゃごちゃ書いていますがコメントの通り以下のようなJSONファイルを読み取ってオブジェクトにすることができます。

{
  // タイルの数
  "rows": 4,
  "columns": 4,
  // トリミングするときの切り取り範囲
  "margin": {
    "left": 20,
    "top": 20,
    "right": 20,
    "bottom": 20
  },
  // 保存するときのグリッド間の余白(上下左右
  "padding_px": 1,
  "make_backup": true,
  "optimize_output": false
}

GridImageTrimServiceクラス

トリミングを行う主処理を担当するクラスです。上記のパラメータークラスを受け取ってグリッドをトリミングして保存します。

using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;

/// <summary>
/// グリッド状に配置されている画像の余白をトリムするためのクラス
/// </summary>
public class GridImageTrimService
{
    /// <summary>
    /// 1グリッドごとに余白をトリムして新しい画像として保存します。
    /// </summary>
    /// <param name="srcImagePath">加工する画像のファイルパス</param>
    /// <param name="param">加工パラメーター</param>
    /// <param name="destImagePath">加工した画像を出力するパス</param>
    public void Trim(string srcImagePath, GridImageTrimParam param, string destImagePath)
    {
        // 補足:
        // 何のパラメーターチェックもせずに処理開始なので注意

        Bitmap srcBmp = new(srcImagePath);

        // 元画像の1区画の大きさ
        Size2Di srcOneSize = new(srcBmp.Width / param.Rows, srcBmp.Height / param.Columns);

        // コピーする画像の大きさ
        Size2Di copyGridsize =
            new(srcOneSize.Width - param.Margin.L - param.Margin.R,
                srcOneSize.Height - param.Margin.T - param.Margin.B);

        // トリム後の1辺の大きさ
        Size2Di destOneSize =
            new(srcOneSize.Width - param.Margin.L - param.Margin.R + param.Padding * 2,
                srcOneSize.Height - param.Margin.T - param.Margin.B + param.Padding * 2);

        // 出力先画像オブジェクトの準備
        int destImgWidth = destOneSize.Width * param.Rows;
        int destImgHeight = destOneSize.Height * param.Columns;
        Bitmap destBmp = new(destImgWidth, destImgHeight, PixelFormat.Format32bppArgb);

        // 元画像を左上から右下に向かって処理していく
        for (int yi = 0; yi < param.Columns; yi++)
        {
            for (int xi = 0; xi < param.Rows; xi++)
            {
                // 元画像の切り取り開始位置
                Point2Di spos =
                    new(srcOneSize.Width * xi + param.Margin.L,
                        srcOneSize.Height * yi + param.Margin.T);

                // 宛先の貼り付け開始位置
                Point2Di dpos =
                    new(destOneSize.Width * xi + param.Padding,
                         destOneSize.Height * yi + param.Padding);

                this.trimOne(srcBmp, destBmp, copyGridsize, spos, dpos);
            }
        }

        using (srcBmp)
        {
            // nop
        }

        if (param.MakeBackup)
        {
            this.backup(destImagePath);
        }

        destBmp.Save(destImagePath, ImageFormat.Png);
        //
        // 補足:
        // このライブラリの保存は圧縮率が考慮されないため
        // こうやって保存しても微妙にサイズが大きいPNGが生成されてしまう
        // 
        //  → より小さいサイズを得たいなら他の高機能ペイントツールでPNGで開いて保存しなおす必要がありそう
        //     もしくは圧縮率を指定できる別のライブラリを使用するとか
        //
    }

    // 1グリッド分の画像をトリムする
    private void trimOne(Bitmap srcBmp, Bitmap destBmp, 
        Size2Di destOneSize, Point2Di spos, Point2Di dpos)
    {
        for (int yi = 0; yi < destOneSize.Height; yi++)
        {
            for (int xi = 0; xi < destOneSize.Width; xi++)
            {
                Color c = srcBmp.GetPixel(spos.X + xi, spos.Y + yi);
                destBmp.SetPixel(dpos.X + xi, dpos.Y + yi, c);
            }
        }
    }

    // ファイルのバックアップ処理
    private void backup(string destFilePath)
    {
        var baseTime = DateTime.Now;
        int i = 0;

        if (!File.Exists(destFilePath))
        {
            return; // バックアップの必要なし
        }

        while (true)
        {
            string suffix = 
                $"__bck__{baseTime:yyyyMMddHHmmssffff}{Path.GetExtension(destFilePath)}";
            string bckFileName =
                Path.GetFileNameWithoutExtension(destFilePath) +suffix;
            string bckFilePath =
                Path.Combine(Path.GetDirectoryName(destFilePath), bckFileName);

            // 他のバックアップを壊さないように可能なファイル名を探索する
            if (File.Exists(bckFilePath))
            {
                baseTime = baseTime.AddMilliseconds(-(--i)); // かぶらない時刻を探す
            }
            else
            {
                File.Move(destFilePath, bckFilePath);
                break;
            }
        }
    }
}

その他クラス

処理中に使用しているクラスは以下の通りです。

Size2Diクラス

横幅と縦幅の2つの値の組み合わせを保持するためのクラスです。

/// <summary>
/// 縦と横の大きさを表します。
/// </summary>
public struct Size2Di
{
    public int Width { get; }
    public int Height { get; }

    public Size2Di(int w, int h)
    {
        this.Width = w;
        this.Height = h;
    }
}
Point2Diクラス

ある位置 X と Y の2つの値の組み合わせを保持するためのクラスです。

/// <summary>
/// 2点 (X, Y) の組み合わせを表します。
/// </summary>
public struct Point2Di
{
    public int X { get; }
    public int Y { get; }

    public Point2Di(int x, int y)
    {
        this.X = x;
        this.Y = y;
    }
}

使い方

で、実際の使い方ですが、コンソールプログラムを以下のように記述して第一引数に設定ファイル、第二引数に変換したいファイル名を渡すようにしています。

internal class AppMain
{
    // args:
    // [0] setting.json のパス
    // [1] 変換対象の PNG のパス

    //[STAThread]
    private static void Main(string[] args)
    {
        AllocConsole();

        try
        {
            int i = 0;
            Console.WriteLine($"args:");
            foreach (string arg in args)
            {
                Console.WriteLine($"[{i++}] {arg}");
            }

            // 設定ファイルの読み込み
            var param = GridImageTrimParam.LoadJson(args[0]);

            // 変換処理実行
            GridImageTrimService service = new();
            service.Trim(args[1], param, args[1]);
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.ToString());
        }
    }

    [DllImport("kernel32.dll")]
    public static extern bool AllocConsole();
}

長々と書きましたが以上です。