ペイントソフトに無いフィルターをOpenCVで作りたいと思うのですが、ペイントソフトからコピー&ペーストの操作で実行出来ると便利かなと思いGUIで作り始めました。
最低限の機能だけと思ったのですが、スクロールや拡大機能などを付けたり、フィルターの結果をクリップボードへセットする機能を付けたりすると、結構コード量が増えてしまいました。
ソースコード
ファイル名:OpenCvFilterTemplate.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OpenCvSharp4" Version="4.11.0.20250507" />
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.11.0.20250507" />
<PackageReference Include="OpenCvSharp4.WpfExtensions" Version="4.11.0.20250507" />
</ItemGroup>
</Project>
ファイル名:App.cs
using System;
using System.Windows;
namespace OpenCvFilterTemplate;
public class App : Application
{
[STAThread]
public static void Main()
{
var app = new App();
app.Startup += (_, __) =>
{
var win = new MainWindow();
win.Show();
};
app.Run();
}
}
ファイル名:AssemblyInfo.cs
using System.Windows;
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]
ファイル名:Window.cs
using OpenCvSharp;
using OpenCvSharp.WpfExtensions;
using System.Collections.Specialized;
using System.Windows.Input;
using System.IO;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Controls.Primitives;
namespace OpenCvFilterTemplate;
public class MainWindow : System.Windows.Window
{
private Mat? _original;
private Mat? _current;
// ===== Zoom (1x start, 1x–8x) =====
private readonly ScaleTransform _scale = new(1.0, 1.0); // 等倍スタート
private readonly Slider _zoomSlider = new()
{
Minimum = 1.0,
Maximum = 8.0,
Value = 1.0,
TickFrequency = 0.25,
IsSnapToTickEnabled = true,
Width = 200,
Margin = new Thickness(8, 0, 8, 0)
};
private readonly TextBlock _zoomLabel = new()
{
Text = "倍率: 1.0x",
Margin = new Thickness(0, 0, 8, 0),
VerticalAlignment = VerticalAlignment.Center
};
// ===== Preview image =====
private readonly Image _preview = new()
{
Stretch = Stretch.None, // 原寸表示(ズームは LayoutTransform で)
SnapsToDevicePixels = true
};
// ===== Status =====
private readonly TextBlock _status = new()
{
Text = "画像をドラッグ&ドロップ or 貼り付けボタンでロード",
Margin = new Thickness(6, 0, 6, 0),
VerticalAlignment = VerticalAlignment.Center
};
// ===== ScrollViewer (Auto) =====
private readonly ScrollViewer _scroller = new()
{
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
Background = Brushes.DarkGray
};
public MainWindow()
{
Title = "OpenCV Filter Template (NoXAML)";
Width = 1100;
Height = 700;
WindowStartupLocation = WindowStartupLocation.CenterScreen;
UseLayoutRounding = true;
// LayoutTransform でレイアウトサイズに倍率を反映(スクロールバーが必要時に出る)
_preview.LayoutTransform = _scale;
Content = BuildUi();
// D&D
AllowDrop = true;
DragEnter += (_, e) =>
{
if (e.Data.GetDataPresent(DataFormats.FileDrop)) e.Effects = DragDropEffects.Copy;
};
Drop += (_, e) =>
{
if (e.Data.GetData(DataFormats.FileDrop) is string[] paths && paths.Length > 0)
{
LoadImageFromPath(paths[0]);
}
};
// スライダー変更で倍率更新
_zoomSlider.ValueChanged += (_, __) => ApplyZoomFromSlider();
}
private UIElement BuildUi()
{
var root = new DockPanel();
// 上:操作ボタン
var topBar = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(6),
};
// ==== Hotkeys ====
AddKeyBinding(Key.V, ModifierKeys.Control, () => CmdPaste()); // Ctrl+V
AddKeyBinding(Key.C, ModifierKeys.Control, () => CmdCopyPng()); // Ctrl+C
AddKeyBinding(Key.G, ModifierKeys.None, () => CmdGrayscale()); // G
AddKeyBinding(Key.R, ModifierKeys.None, () => CmdReset()); // R
AddKeyBinding(Key.Escape, ModifierKeys.None, () => Close()); // Esc
topBar.Children.Add(MkBtn("貼り付け(Ctrl+V)", () => CmdPaste()));
topBar.Children.Add(MkBtn("コピー PNG(Ctrl+C)", () => CmdCopyPng()));
topBar.Children.Add(MkSep());
topBar.Children.Add(MkBtn("グレースケール(G)", () => CmdGrayscale()));
topBar.Children.Add(MkBtn("リセット(R)", () => CmdReset()));
DockPanel.SetDock(topBar, Dock.Top);
root.Children.Add(topBar);
// 下:ステータスバー(倍率スライダー)
var statusPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
VerticalAlignment = VerticalAlignment.Center
};
statusPanel.Children.Add(new TextBlock
{
Text = "拡大率(1x〜8x):",
Margin = new Thickness(8, 0, 6, 0),
VerticalAlignment = VerticalAlignment.Center
});
statusPanel.Children.Add(_zoomSlider);
statusPanel.Children.Add(_zoomLabel);
statusPanel.Children.Add(new Separator { Margin = new Thickness(8, 0, 8, 0), Width = 1, Opacity = 0.5 });
statusPanel.Children.Add(_status);
var statusBar = new Border
{
BorderBrush = Brushes.LightGray,
BorderThickness = new Thickness(0, 1, 0, 0),
Padding = new Thickness(0, 4, 0, 4),
Child = statusPanel
};
DockPanel.SetDock(statusBar, Dock.Bottom);
root.Children.Add(statusBar);
// 中央:スクロール+プレビュー
_scroller.Content = new Border
{
Background = Brushes.Black,
Padding = new Thickness(8),
Child = _preview
};
root.Children.Add(_scroller);
return root;
}
private static UIElement MkSep() => new Border
{
Width = 1,
Height = 28,
Margin = new Thickness(6, 0, 6, 0),
Background = Brushes.LightGray
};
private static Button MkBtn(string text, Action onClick) =>
new Button
{
Content = text,
Margin = new Thickness(0, 0, 6, 0),
Padding = new Thickness(10, 6, 10, 6),
VerticalAlignment = VerticalAlignment.Center
}.Tap(b => b.Click += (_, __) => onClick());
// ===== Zoom =====
private void ApplyZoomFromSlider()
{
var z = Math.Clamp(_zoomSlider.Value, _zoomSlider.Minimum, _zoomSlider.Maximum);
_scale.ScaleX = z;
_scale.ScaleY = z;
_zoomLabel.Text = $"倍率: {z:0.0}x";
// LayoutTransform はレイアウトに反映されるので、InvalidateMeasure 程度で十分
_preview.InvalidateMeasure();
}
// ===== Commands =====
private void CmdPaste()
{
try
{
// 2) PNG(アルファ保持)
if (Clipboard.ContainsData("PNG") && Clipboard.GetData("PNG") is Stream pngStream)
{
using var ms = new MemoryStream();
pngStream.CopyTo(ms);
var bytes = ms.ToArray();
var mat = Cv2.ImDecode(bytes, ImreadModes.Unchanged);
if (!mat.Empty())
{
LoadImageFromMat(mat, "Clipboard PNG");
return;
}
}
// 1) DIB(MS Paint 対応:α=0 問題を回避)
if (TryGetClipboardMatFromDib(out var matFromDib))
{
LoadImageFromMat(matFromDib, "Clipboard DIB");
return;
}
// 3) WPF Image(フォールバック)
if (Clipboard.ContainsImage())
{
var bmp = Clipboard.GetImage();
if (bmp is not null)
{
using var mat = bmp.ToMat(); // ここは従来通り
LoadImageFromMat(mat.Clone(), "Clipboard Bitmap");
return;
}
}
// 4) ファイルパス
if (Clipboard.ContainsFileDropList())
{
var files = Clipboard.GetFileDropList();
if (files is { Count: > 0 })
{
var path = files[0];
if (path is not null)
{
LoadImageFromPath(path);
}
return;
}
}
SetStatus("貼り付け可能な画像がありません");
}
catch (Exception ex)
{
SetStatus("貼り付けエラー: " + ex.Message);
}
}
private enum DibAlphaPolicy { AutoFixPaint, Preserve, ForceOpaque }
// DIB(CF_DIB/DeviceIndependentBitmap)から Mat を作る(24/32bpp, BI_RGB)※unsafe 不使用
private static bool TryGetClipboardMatFromDib(out Mat mat, DibAlphaPolicy policy = DibAlphaPolicy.AutoFixPaint)
{
mat = null!;
try
{
const string Dib = "DeviceIndependentBitmap";
if (!Clipboard.ContainsData(Dib)) return false;
if (Clipboard.GetData(Dib) is not Stream dibStream) return false;
dibStream.Position = 0;
using var br = new BinaryReader(dibStream, System.Text.Encoding.Default, leaveOpen: true);
int headerSize = br.ReadInt32(); // biSize
int width = br.ReadInt32(); // biWidth
int heightRaw = br.ReadInt32(); // biHeight(<0 はトップダウン)
bool topDown = heightRaw < 0;
int height = Math.Abs(heightRaw);
br.ReadInt16(); // biPlanes
short bpp = br.ReadInt16(); // biBitCount (24 or 32)
int compression = br.ReadInt32(); // biCompression (0: BI_RGB)
int imageSize = br.ReadInt32(); // biSizeImage(0 のことも)
br.ReadInt32(); // biXPelsPerMeter
br.ReadInt32(); // biYPelsPerMeter
br.ReadInt32(); // biClrUsed
br.ReadInt32(); // biClrImportant
if (compression != 0) return false; // BI_RGB のみ対応
dibStream.Position = headerSize;
int srcStride = ((width * bpp + 31) / 32) * 4; // DIB 行境界
int dstStride = width * 4; // 受け側は常に BGRA32
var srcRow = new byte[srcStride];
var dstBuf = new byte[dstStride * height];
// αの扱いを決めるための簡易判定(Paint対策)
bool allAlphaZero = true; // 全画素 α==0 ?
bool anyColorNonZero = false; // どこかに色(BGRいずれか≠0)がある?
for (int y = 0; y < height; y++)
{
int read = dibStream.Read(srcRow, 0, srcStride);
if (read < srcStride) return false;
int dstY = topDown ? y : (height - 1 - y);
int dstBase = dstY * dstStride;
if (bpp == 32)
{
for (int x = 0; x < width; x++)
{
int s = x * 4;
int d = dstBase + x * 4;
byte b = srcRow[s + 0];
byte g = srcRow[s + 1];
byte r = srcRow[s + 2];
byte a = srcRow[s + 3]; // まずは「保持」
if (a != 0) allAlphaZero = false;
if ((b | g | r) != 0) anyColorNonZero = true;
dstBuf[d + 0] = b;
dstBuf[d + 1] = g;
dstBuf[d + 2] = r;
dstBuf[d + 3] = a; // ここではそのまま入れる
}
}
else if (bpp == 24)
{
for (int x = 0; x < width; x++)
{
int s = x * 3;
int d = dstBase + x * 4;
byte b = srcRow[s + 0];
byte g = srcRow[s + 1];
byte r = srcRow[s + 2];
if ((b | g | r) != 0) anyColorNonZero = true;
dstBuf[d + 0] = b;
dstBuf[d + 1] = g;
dstBuf[d + 2] = r;
dstBuf[d + 3] = 255; // 24bpp は常に不透明
}
}
else
{
return false; // 8bpp 等は未対応
}
}
// αの最終処理ポリシー
bool forceOpaque =
policy == DibAlphaPolicy.ForceOpaque ||
(policy == DibAlphaPolicy.AutoFixPaint && allAlphaZero && anyColorNonZero); // Paint の典型
if (forceOpaque && bpp == 32)
{
for (int i = 3; i < dstBuf.Length; i += 4) dstBuf[i] = 255;
}
mat = new Mat(height, width, MatType.CV_8UC4);
Marshal.Copy(dstBuf, 0, mat.Data, dstBuf.Length);
return true;
}
catch
{
mat = null!;
return false;
}
}
private void CmdCopyPng()
{
if (!EnsureLoaded()) return;
try
{
var bmp = _current!.ToBitmapSource();
using var ms = new MemoryStream();
var enc = new PngBitmapEncoder();
enc.Frames.Add(BitmapFrame.Create(bmp));
enc.Save(ms);
ms.Position = 0;
var data = new DataObject();
data.SetData("PNG", ms);
data.SetData(DataFormats.Bitmap, bmp);
Clipboard.SetDataObject(data, true);
SetStatus("クリップボードへ PNG コピー(アルファ保持)");
}
catch (Exception ex)
{
SetStatus("コピー失敗: " + ex.Message);
}
}
private void CmdGrayscale()
{
if (!EnsureLoaded()) return;
ApplyFilter("Grayscale", src =>
{
if (src.Type().Channels == 1)
return src.Clone();
var dst = new Mat();
var code = src.Channels() == 4 ? ColorConversionCodes.BGRA2GRAY : ColorConversionCodes.BGR2GRAY;
Cv2.CvtColor(src, dst, code);
return dst;
});
}
private void CmdReset()
{
if (_original is null)
{
SetStatus("画像がありません");
return;
}
_current?.Dispose();
_current = _original.Clone();
UpdatePreview(_current);
SetStatus("リセット");
}
// ===== Core =====
private void LoadImageFromPath(string path)
{
try
{
var bytes = File.ReadAllBytes(path);
var mat = Cv2.ImDecode(bytes, ImreadModes.Unchanged);
if (mat.Empty())
{
SetStatus("読み込みに失敗しました");
return;
}
LoadImageFromMat(mat, path);
}
catch (Exception ex)
{
SetStatus("読み込みエラー: " + ex.Message);
}
}
private void LoadImageFromMat(Mat mat, string? sourceHint = null)
{
_original?.Dispose();
_current?.Dispose();
_original = mat;
_current = mat.Clone();
UpdatePreview(_current);
SetStatus($"読み込み: {sourceHint ?? "Memory"} ({_current.Width}x{_current.Height}, ch={_current.Channels()})");
// 現在のスライダー倍率を適用(等倍スタート)
ApplyZoomFromSlider();
// 先頭へスクロール
_scroller.ScrollToHorizontalOffset(0);
_scroller.ScrollToVerticalOffset(0);
}
private void ApplyFilter(string name, Func<Mat, Mat> filter)
{
if (!EnsureLoaded()) return;
try
{
using var src = _current!;
var dst = filter(src);
_current = dst;
UpdatePreview(_current);
SetStatus($"適用: {name}");
}
catch (Exception ex)
{
SetStatus($"{name} エラー: " + ex.Message);
}
}
private bool EnsureLoaded()
{
if (_current is null)
{
SetStatus("画像をドラッグ&ドロップ or 貼り付けで読み込んでください");
return false;
}
return true;
}
private void UpdatePreview(Mat mat)
{
_preview.Source = mat.ToBitmapSource();
}
private void SetStatus(string msg) => _status.Text = msg;
protected override void OnClosed(EventArgs e)
{
base.OnClosed(e);
_current?.Dispose();
_original?.Dispose();
}
// ==== Hotkey helper ====
private readonly Dictionary<Key, Action> _noModHotkeys = new();
private bool _noModHooked;
private void AddKeyBinding(Key key, ModifierKeys mods, Action action)
{
if (mods == ModifierKeys.None)
{
_noModHotkeys[key] = action;
EnsureNoModHook();
return;
}
var cmd = new RoutedCommand();
cmd.InputGestures.Add(new KeyGesture(key, mods));
CommandBindings.Add(new CommandBinding(cmd, (_, __) => action()));
}
private void EnsureNoModHook()
{
if (_noModHooked) return;
_noModHooked = true;
PreviewKeyDown += (_, e) =>
{
if (Keyboard.Modifiers != ModifierKeys.None) return;
if (!_noModHotkeys.TryGetValue(e.Key, out var act)) return;
if (IsTextInputFocused()) return; // テキスト入力に干渉しない
act();
e.Handled = true;
};
}
private bool IsTextInputFocused()
{
var fe = FocusManager.GetFocusedElement(this);
return fe is TextBoxBase || fe is PasswordBox || fe is ComboBox || fe is RichTextBox;
}
}
internal static class ObjectExt
{
public static T Tap<T>(this T self, Action<T> action)
{
action(self);
return self;
}
}
実行イメージ
フィルターを差し替えて拡張
サンプルコードはグレースケール化するフィルターが仕込まれていますが、こちらのフィルター部分のコードを差し替えると色々なフィルターに対応出来ると思います。
private void CmdGrayscale()
{
if (!EnsureLoaded()) return;
ApplyFilter("Grayscale", src =>
{
if (src.Type().Channels == 1)
return src.Clone();
var dst = new Mat();
var code = src.Channels() == 4 ? ColorConversionCodes.BGRA2GRAY : ColorConversionCodes.BGR2GRAY;
Cv2.CvtColor(src, dst, code);
return dst;
});
}
ApplyFilter
の内容を差し替えることになります。引数のsrcでMat形式の画像オブジェクトが渡されるので、そのオブジェクトを元にフィルター処理を行い、戻り値に処理後のMatオブジェクトを返す仕様です。
サンプルコードでは、まずsrcのチャンネル数が1チャンネルの場合、グレースケールと決めつけて、srcのクローンを返しています。
if (src.Type().Channels == 1)
return src.Clone();
戻り値用のMatオブジェクト生成
var dst = new Mat();
チャンネル数が4の場合BGRAそれ以外はBGRと決め打ちし、GRAYへ変換オプションをセット
var code = src.Channels() == 4 ? ColorConversionCodes.BGRA2GRAY : ColorConversionCodes.BGR2GRAY;
CvtColor()
メソッドでグレースケールへ変換しています。その後、結果のdstオブジェクトをreturnで返します。
Cv2.CvtColor(src, dst, code);
return dst;
グレースケールの処理の必要なパラメータはピクセル深度ぐらいですが、
実際使う様々なフィルター処理は、色々なパラメータが必要になり、UI側でパラメタをセットするコントロールを用意することが一般的です。
ただ、個人のツールであれば、パラメタ固定で特定の処理に特化したアプリにするのも良いのではないかと思います。
コメント