WinFormsで画像にガウスぼかしをかける(C#)——3つの実装と最小サンプル

TL;DR

すぐ動かすなら OpenCvSharp、純 .NET なら ImageSharp、依存を増やしたくない/仕組みを学びたいなら LockBits + 分離畳み込み(自前実装)。WinForms では UI フリーズを避けるために必ず Task.Run 等でワーカースレッド実行に。


この記事で得られるもの

  • WinForms でガウスぼかしをかける 3 通りの方法
  • 最小 UI(PictureBox + TrackBar + ボタン)で動く サンプルコード
  • 自前実装(LockBits + 分離畳み込み)の 高速・高品質な基礎実装
  • 画質と速度の チューニング指針 と 落とし穴

注: .NET 6 以降の System.Drawing.Common は Windows 専用です(WinForms は問題ありません)。


アプローチ早見表

方法依存速度/品質学習コストこんな人に
OpenCvSharpOpenCVとても速い/高品質まず動かしたい、速度重視
ImageSharpSixLabors.ImageSharp速い/高品質純 .NET で完結したい
自前実装なし(unsafe 推奨)速い(最適化次第)/高品質中〜高仕組みを理解・制御したい

1) OpenCvSharp で最短実装(おすすめ)

セットアップ

  • NuGet: OpenCvSharp4, OpenCvSharp4.runtime.win, OpenCvSharp4.Extensions

プラットフォーム変更

  1. プロジェクトの [プラットフォーム ターゲット] を x64 にして [32 ビット優先] をオフ
  2. いったん Clean → Rebuild(bin/obj を消すと確実)

最小コード(pictureBox1 の画像に σ を適用)

using OpenCvSharp;
using OpenCvSharp.Extensions;

double sigma = 2.0; // ぼかし強度
using var mat = BitmapConverter.ToMat((Bitmap)pictureBox1.Image);
using var dst = new Mat();
Cv2.GaussianBlur(mat, dst, new Size(0, 0), sigma);
pictureBox1.Image?.Dispose();
pictureBox1.Image = BitmapConverter.ToBitmap(dst);
  • Size(0, 0) を渡すと カーネルサイズは σ から自動決定されます。
  • 高速・高品質。色ずれやエッジのアーティファクトが少ないです。

2) ImageSharp(純 .NET)

セットアップ

  • NuGet: SixLabors.ImageSharp, SixLabors.ImageSharp.Drawing(必要に応じて)

最小コード

using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;

float sigma = 2.0f;
using var msIn = new MemoryStream();
((Bitmap)pictureBox1.Image).Save(msIn, System.Drawing.Imaging.ImageFormat.Png);
msIn.Position = 0;

using var img = Image.Load(msIn);
img.Mutate(x => x.GaussianBlur(sigma));

using var msOut = new MemoryStream();
img.SaveAsPng(msOut);
msOut.Position = 0;

pictureBox1.Image?.Dispose();
pictureBox1.Image = new Bitmap(msOut);
  • 依存は .NET のみで完結。クロスプラットフォーム志向のコードベースにも流用可能です。

3) 依存なし・自前実装(LockBits + 分離畳み込み)

ポイント

  • ガウスカーネルは 水平→垂直 の 2 回に分けて畳み込み(分離畳み込み)すると高速。
  • カーネル半径は経験則で radius = ceil(3 * σ) が目安。
  • GetPixel/SetPixel は非常に遅いので LockBits + unsafe を推奨。
  • 端の画素は クランプ(端値の繰り返し) で処理。

実装(シングルファイル)

using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.Threading.Tasks;

public static class GaussianBlur
{
    public static Bitmap Apply(Bitmap source, double sigma, int radius = -1)
    {
        if (source == null) throw new ArgumentNullException(nameof(source));
        if (sigma <= 0) throw new ArgumentOutOfRangeException(nameof(sigma));

        int r = radius > 0 ? radius : Math.Max(1, (int)Math.Ceiling(sigma * 3));
        double[] kernel = BuildKernel(sigma, r);

        // 32bpp ARGB に正規化
        Bitmap src32 = source.PixelFormat == PixelFormat.Format32bppArgb
            ? (Bitmap)source.Clone()
            : source.Clone(new Rectangle(0, 0, source.Width, source.Height), PixelFormat.Format32bppArgb);

        Bitmap tmp = new(src32.Width, src32.Height, PixelFormat.Format32bppArgb);
        Bitmap dst = new(src32.Width, src32.Height, PixelFormat.Format32bppArgb);

        var srcData = src32.LockBits(new Rectangle(0, 0, src32.Width, src32.Height),
                                     ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
        var tmpData = tmp.LockBits(new Rectangle(0, 0, tmp.Width, tmp.Height),
                                   ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb);
        var dstData = dst.LockBits(new Rectangle(0, 0, dst.Width, dst.Height),
                                   ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb);

        try
        {
            unsafe
            {
                int w = src32.Width;
                int h = src32.Height;

                // 横方向
                byte* s0 = (byte*)srcData.Scan0;
                byte* t0 = (byte*)tmpData.Scan0;
                int sStride = srcData.Stride;
                int tStride = tmpData.Stride;

                Parallel.For(0, h, y =>
                {
                    byte* sRow = s0 + y * sStride;
                    byte* tRow = t0 + y * tStride;

                    for (int x = 0; x < w; x++)
                    {
                        double b = 0, g = 0, rCh = 0, a = 0;
                        for (int k = -r; k <= r; k++)
                        {
                            int xi = x + k;
                            if (xi < 0) xi = 0;
                            else if (xi >= w) xi = w - 1;

                            byte* p = sRow + xi * 4;
                            double kk = kernel[k + r];
                            b   += p[0] * kk;
                            g   += p[1] * kk;
                            rCh += p[2] * kk;
                            a   += p[3] * kk;
                        }
                        byte* d = tRow + x * 4;
                        d[0] = ToByte(b);
                        d[1] = ToByte(g);
                        d[2] = ToByte(rCh);
                        d[3] = ToByte(a);
                    }
                });

                // 縦方向
                byte* tt0 = (byte*)tmpData.Scan0;
                byte* d0  = (byte*)dstData.Scan0;
                int dStride = dstData.Stride;

                Parallel.For(0, w, x =>
                {
                    for (int y = 0; y < h; y++)
                    {
                        double b = 0, g = 0, rCh = 0, a = 0;
                        for (int k = -r; k <= r; k++)
                        {
                            int yi = y + k;
                            if (yi < 0) yi = 0;
                            else if (yi >= h) yi = h - 1;

                            byte* p = tt0 + yi * tStride + x * 4;
                            double kk = kernel[k + r];
                            b   += p[0] * kk;
                            g   += p[1] * kk;
                            rCh += p[2] * kk;
                            a   += p[3] * kk;
                        }
                        byte* d = d0 + y * dStride + x * 4;
                        d[0] = ToByte(b);
                        d[1] = ToByte(g);
                        d[2] = ToByte(rCh);
                        d[3] = ToByte(a);
                    }
                });
            }
        }
        finally
        {
            src32.UnlockBits(srcData);
            tmp.UnlockBits(tmpData);
            dst.UnlockBits(dstData);
            src32.Dispose();
            tmp.Dispose();
        }

        return dst;
    }

    private static double[] BuildKernel(double sigma, int r)
    {
        double[] k = new double[2 * r + 1];
        double sigma2 = sigma * sigma;
        double sum = 0;
        for (int i = -r; i <= r; i++)
        {
            double v = Math.Exp(-(i * i) / (2 * sigma2));
            k[i + r] = v;
            sum += v;
        }
        // 正規化
        for (int i = 0; i < k.Length; i++) k[i] /= sum;
        return k;
    }

    private static byte ToByte(double v)
    {
        if (v <= 0) return 0;
        if (v >= 255) return 255;
        return (byte)Math.Round(v);
    }
}

使用例(UIスレッドをブロックしない)

private async void btnBlur_Click(object sender, EventArgs e)
{
    if (pictureBox1.Image is not Bitmap src) return;

    double sigma = trackBarSigma.Value / 10.0; // 例: 10〜50 → 1.0〜5.0
    var blurred = await Task.Run(() => GaussianBlur.Apply(src, sigma));
    var old = pictureBox1.Image;
    pictureBox1.Image = blurred;
    old?.Dispose();
}

最小フォーム例(そのまま貼って動く)

  • コントロール: PictureBox pictureBox1, TrackBar trackBarSigma (Min=10, Max=50, Value=20), Button btnOpen, Button btnBlur
  • 画像読込→スライダーで σ 調整→「ぼかす」で適用
using System;
using System.Drawing;
using System.IO;
using System.Windows.Forms;

public partial class Form1 : Form
{
    public Form1()
    {
        InitializeComponent();
        trackBarSigma.Minimum = 10;  // 1.0
        trackBarSigma.Maximum = 50;  // 5.0
        trackBarSigma.Value = 20;    // 2.0
        trackBarSigma.TickFrequency = 5;
        trackBarSigma.ValueChanged += (_, __) => this.Text = $"σ={trackBarSigma.Value / 10.0:F1}";
        this.Text = $"σ={trackBarSigma.Value / 10.0:F1}";
    }

    private void btnOpen_Click(object sender, EventArgs e)
    {
        using var ofd = new OpenFileDialog
        {
            Filter = "画像ファイル|*.png;*.jpg;*.jpeg;*.bmp",
            RestoreDirectory = true
        };
        if (ofd.ShowDialog() == DialogResult.OK)
        {
            pictureBox1.Image?.Dispose();
            pictureBox1.Image = new Bitmap(ofd.FileName);
        }
    }

    private async void btnBlur_Click(object sender, EventArgs e)
    {
        if (pictureBox1.Image is not Bitmap src) return;
        double sigma = trackBarSigma.Value / 10.0;

        // 重い処理は別スレッドで
        var blurred = await Task.Run(() => GaussianBlur.Apply(src, sigma));
        var old = pictureBox1.Image;
        pictureBox1.Image = blurred;
        old?.Dispose();
    }
}

画質・速度のコツ

  1. カーネル半径: radius = ceil(3 * σ) を基準に。大きくしすぎると計算量が増えるだけで効果は飽和します。
  2. 分離畳み込み: 2D 畳み込み((2r+1)²)を 横+縦(2×(2r+1)) に分解して高速化。
  3. 並列化: Parallel.For は大きい画像で有効。小さい画像ではオーバーヘッドに注意。
  4. UI フリーズ対策: Task.Run + await で非同期実行。
  5. 透明画素: 透過 PNG などは アルファも一緒にぼかすとエッジが柔らかくなります。ハローが気になる場合は プリマルチプライド(premultiplied alpha) で計算→復元が最良。
  6. ダウンサンプル・アップサンプル: ふわっとした背景用途なら、縮小→ぼかし→拡大で高速化。
  7. GetPixel/SetPixel 禁止: デモ用途以外では使用しない(極端に遅い)。
  8. ディスポーズ: Bitmap.Dispose() を忘れない(メモリリーク対策)。

よくある落とし穴

  • System.Drawing のプラットフォーム制限: .NET 6+ で Windows 以外は非推奨。WinForms なら Windows 前提なので OK。
  • Stride の扱い: 画像幅×4 が必ずしも Stride と一致しません。LockBits の Stride を必ず使う。
  • 巨大画像: Task.Run でも処理が長い場合は 進捗表示 や キャンセル(CancellationToken)を検討。
  • 端処理: クランプ以外(ミラー、ラップ)にすると見え方が変わります。要件に合わせて実装を差し替え。

演習

  1. σリアルタイムプレビュートラックバーが動いたら 300ms デバウンスして自動ぼかし。System.Windows.Forms.Timer を使い、連続操作でも滑らかに。
  2. ボックスぼかし×3でガウス近似3 回のボックスフィルタでガウスを近似。自前実装で速度比較し、画質差を観察。
  3. プリマルチプライド対応アルファ付き PNG を用意し、premultiplied alpha でのぼかし→非プリマルチ復元を追加して輪郭の差を確認。

まとめ

  • まずは OpenCvSharp で最短・高品質を確保。
  • 依存を避けたい/学習目的なら LockBits + 分離畳み込み が王道。
  • σ と半径非同期化アルファ処理 を押さえれば、実践用のガウスぼかしは完成です。
訪問数 6 回, 今日の訪問数 6回