PowerShell と C# で画像処理（ピクセル操作）

# 概要
- 以下の記事の C# プログラムを少しアレンジして、PowerShell から利用してみた。
    - [[C#] ビットマップにピクセル単位で高速にアクセスするには (GetPixel/SetPixel vs BitmapData 速度比較) – 端くれプログラマの備忘録:）](http://www.84kure.com/blog/2014/07/13/c-%E3%83%93%E3%83%83%E3%83%88%E3%83%9E%E3%83%83%E3%83%97%E3%81%AB%E3%83%94%E3%82%AF%E3%82%BB%E3%83%AB%E5%8D%98%E4%BD%8D%E3%81%A7%E9%AB%98%E9%80%9F%E3%81%AB%E3%82%A2%E3%82%AF%E3%82%BB%E3%82%B9/)

</br>

- この記事でやっていること:
    0. **C#** で、画像ファイルをピクセルデータ（バイト配列）に変換したり、戻したりする。
    0. **PowerShell** で、画像処理（ピクセル操作）を行い、加工した画像を別のファイルに保存する。

- PowerShell で手軽に画像処理のプログラムが書けるようになる、かもしれない。

# コード
## ビットマップとピクセルデータ間の変換処理を C# で記述する

- 今回は、以下の 3 つのクラスを詰め込んだ名前空間 **ImagingUtil** を用意した。
    0. RGBA のバイト情報をピクセル単位で扱えるようにするための **Pixel** クラス
    0. ビットマップとピクセルデータ間の変換処理を行う **BitmapConverter** クラス
    0. 画像ファイルの保存形式 [ImageFormat](https://msdn.microsoft.com/library/system.drawing.imaging.imageformat(v=ｆvs.110).aspx) を決定する **ImageFormatResolver** クラス

```csharp:ImagingUtil.cs
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;

namespace ImagingUtil
{
    // RGBA のバイト情報をピクセル単位で扱えるようにするためのクラス
    public class Pixel
    {
        public byte a;
        public byte r;
        public byte g;
        public byte b;

        public Pixel(byte b, byte g, byte r, byte a)
        {
            this.a = a;
            this.r = r;
            this.g = g;
            this.b = b;
        }
    }

    // ビットマップとピクセルデータ間の変換処理を行うクラス
    public static class BitmapConverter
    {
        // ビットマップをピクセルデータに変換する
        public static IList<Pixel> ConvertBitmapToPixels(Bitmap bitmap)
        {
            byte[] byteArray = null;
            using (bitmap)
            {
                byteArray = ConvertBitmapToByteArray(bitmap);
            }

            var ret = new List<Pixel>();
            for (var i = 0; i < byteArray.Length; i += 4)
            {
                ret.Add(new Pixel(byteArray[i + 0],
                                  byteArray[i + 1],
                                  byteArray[i + 2],
                                  byteArray[i + 3]));
            }
            return ret;
        }

        // ビットマップをバイト配列に変換する
        private static byte[] ConvertBitmapToByteArray(Bitmap bitmap)
        {
            var ret = new byte[bitmap.Width * bitmap.Height * 4];

            BitmapData bitmapData = bitmap.LockBits(
                    new Rectangle(0, 0, bitmap.Width, bitmap.Height),
                    ImageLockMode.ReadOnly,
                    PixelFormat.Format32bppArgb);

            Marshal.Copy(bitmapData.Scan0, ret, 0, ret.Length);

            bitmap.UnlockBits(bitmapData);

            return ret;
        }

        // ピクセルデータをビットマップに戻す
        public static Bitmap ConvertPixelsToBitmap(IList<Pixel> pixels, int width, int height)
        {
            var byteArray = new byte[width * height * 4];
            var index = 0;
            foreach (var pixel in pixels)
            {
                byteArray[index++] = pixel.b;
                byteArray[index++] = pixel.g;
                byteArray[index++] = pixel.r;
                byteArray[index++] = pixel.a;
            }
            return ConvertByteArrayToBitmap(byteArray, width, height);
        }

        // バイト配列をビットマップに戻す
        private static Bitmap ConvertByteArrayToBitmap(byte[] byteArray, int width, int height)
        {
            Bitmap ret = new Bitmap(width, height);

            BitmapData bitmapData = ret.LockBits(
                    new Rectangle(0, 0, ret.Width, ret.Height),
                    ImageLockMode.ReadWrite,
                    PixelFormat.Format32bppArgb);

            Marshal.Copy(byteArray, 0, bitmapData.Scan0, byteArray.Length);

            ret.UnlockBits(bitmapData);

            return ret;
        }
    }

    // 画像ファイルの保存形式 ImageFormat を決定するクラス
    public static class ImageFormatResolver
    {
        // 画像ファイルの拡張子から決定する
        public static ImageFormat ResolveFromExtension(string extension)
        {
            switch (extension.ToLower())
            {
                case ".bmp": return ImageFormat.Bmp;
                case ".gif": return ImageFormat.Gif;
                case ".jpg":
                case ".jpeg": return ImageFormat.Jpeg;
                case ".png":
                default: return ImageFormat.Png;
            }
        }
    }
}
```

## 画像処理の部分を PowerShell で記述する
0. まず、画像ファイルのデータをピクセルデータに変換する。
    - 変換には、上記の **ImagingUtil.BitmapConverter** を利用する。
    - 元となる画像ファイルへのファイルパスは、スクリプトの引数で指定する。
0. ピクセルデータを操作する。
    - 今回はピクセルデータに対して、簡単なグレースケール変換を行っている。
        - RGB 値を加算して 3 で割るだけ:

            ```ps1
            $grayValue = [byte](($pixel.r + $pixel.g + $pixel.b) / 3)
            ```

0. ピクセルデータを画像データに戻す。
0. 変換した画像データは、元となった画像ファイルと同じ場所に、ファイル名の末尾に **"_gray"** と付けて保存する。

```ps1:ConvertTo-Grayscale.ps1
param(
    [Parameter(Mandatory = $true)]
    [string]
    $SourceImageFilePath
)

# 画像ファイルのパスを解決
$resolvedFilePath = Resolve-Path $SourceImageFilePath

# 画像の情報を取得
Add-Type -AssemblyName System.Drawing
$sourceImage = [System.Drawing.Image]::FromFile($resolvedFilePath)
$imageWidth = $sourceImage.Width
$imageHeight = $sourceImage.Height

# 'ImagingUtil.cs' をロード
$thisDirectoryPath = Split-Path $MyInvocation.MyCommand.Path -Parent
Add-Type -Path (Join-Path $thisDirectoryPath 'ImagingUtil.cs') `
         -ReferencedAssemblies System.Drawing

# 画像データをピクセルデータに変換
$pixels = [ImagingUtil.BitmapConverter]::ConvertBitmapToPixels($sourceImage)
if ($sourceImage -ne $null)
{
    $sourceImage.Dispose()
}

# 画像処理 （グレースケール変換）
foreach ($pixel in $pixels)
{
    $grayValue = [byte](($pixel.r + $pixel.g + $pixel.b) / 3)
    $pixel.r = $grayValue
    $pixel.g = $grayValue
    $pixel.b = $grayValue
}

# ピクセルデータを画像データに戻す
$outputImage = [ImagingUtil.BitmapConverter]::ConvertPixelsToBitmap($pixels, $imageWidth, $imageHeight)

# 変換した画像データを保存する（元画像と同じ場所に、ファイル名の末尾に "_gray" と付けて保存）
$outputFileLocation = Split-Path $resolvedFilePath -Parent
$outputFileBaseName = (Get-ChildItem $resolvedFilePath).BaseName + "_gray"
$outputFileExtention = (Get-ChildItem $resolvedFilePath).Extension
$outputFileFullName = $outputFileBaseName + $outputFileExtention
$outputFilePath = Join-Path $outputFileLocation $outputFileFullName
$outputImageFormat = [ImagingUtil.ImageFormatResolver]::ResolveFromExtension($outputFileExtention)
$outputImage.Save($outputFilePath, $outputImageFormat)
```

# 実行結果

```
PS C:\> & .\ConvertTo-GrayScale.ps1 neko.jpg
```
## ビフォー
[neko.jpg](https://www.pakutaso.com/20160532138post-7870.html)
![neko.jpg](https://qiita-image-store.s3.amazonaws.com/0/155410/ad4933a6-e9eb-3421-faa8-66cb6e905486.jpeg)

## アフター
neko_gray.jpg
![neko_gray.jpg](https://qiita-image-store.s3.amazonaws.com/0/155410/7a35b671-80c1-67ee-d7d4-72c23c5aa6e8.jpeg)

# 考察

- 今回の例では PowerShell 側での画像処理のしやすさを考慮して、C# 側で `byte[]` と `IList<Pixel>` の相互変換を行っているが、このオーバーヘッドのせいで処理が遅くなってしまっている。
    - 処理速度を気にする場合は、画像処理の部分も C# 側に記述してもよいかもしれない（PowerShell 側はそれらを利用するだけにする）。

## 追記
画像処理のちょっと凝ったロジックを PowerShell で書くのは難しいと、[他のフィルタ](https://github.com/saggie/psimaging/blob/master/scripts/ImageProcesser.cs#L73-L95)を作っていて感じました。ロジックを C# に寄せて書いた版を[ここ](https://github.com/saggie/psimaging)に載せます。