WPF基于Opencvsharp4实现水漫(PS魔棒)效果

19 阅读4分钟

项目框架: WPF(.net 8.0) , Opencvsharp4.

核心方法就是 Cv2.FloodFill的实现,即选择一个点,然后算法会自动填充与该点颜色相似的所有相邻区域。

效果预览如下

screenshots.gif

xaml设计界面

image.png

viewmodel定义

        #region 输入输出图像路径

        private string _srcImgPath;

        public string SrcImgPath
        {
            get => _srcImgPath;
            set
            {
                _srcImgPath = value;
                OnPropertyChanged();
            }
        }

        private string _saveImagePath;

        public string SaveImagePath
        {
            get => _saveImagePath;
            set
            {
                _saveImagePath = value;
                OnPropertyChanged();
            }
        }

        #endregion 输入输出图像路径
        
        #region 图像集合 用于保存每步操作 可撤销

        private ObservableCollection<Mat> _matCollection = new ObservableCollection<Mat>();
        public ObservableCollection<Mat> MatCollection => _matCollection;
        private Mat _lastMat;

        public Mat LastMat
        {
            get => _lastMat;
            set
            {
                _lastMat = value;
                OnPropertyChanged();
            }
        }

        private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
        {
            LastMat = _matCollection.LastOrDefault();
        }

        private void Push(Mat mat)
        {
            _matCollection.Add(mat);
        }

        private Mat Pop()
        {
            if (_matCollection.Count <= 1)
            {
                return null;
            }
            var last = _matCollection.Last();
            _matCollection.RemoveAt(_matCollection.Count - 1);
            return last;
        }

        #endregion 图像集合 用于保存每步操作 可撤销
        
        private System.Windows.Media.Color _floodFillColor = Colors.Red;

        public System.Windows.Media.Color FloodFillColor
        {
            get { return _floodFillColor; }
            set
            {
                _floodFillColor = value;
                OnPropertyChanged();
            }
        }
        
                #region 正负差值

        private int _downDiff = 0;

        public int DownDiff
        {
            get => _downDiff;
            set
            {
                _downDiff = value;
                OnPropertyChanged();
            }
        }

        private int _upDiff = 0;

        public int UpDiff
        {
            get => _upDiff;
            set
            {
                _upDiff = value;
                OnPropertyChanged();
            }
        }

        #endregion 正负差值
        
        private bool _isCoordinateModeEnabled = false;
        private CoordinateModel _currentCoordinate = new CoordinateModel();
        private CoordinateModel _selectedCoordinate = new CoordinateModel();
        private string _statusMessage;

        public bool IsCoordinateModeEnabled
        {
            get => _isCoordinateModeEnabled;
            set
            {
                _isCoordinateModeEnabled = value;
                OnPropertyChanged();

                // 更新状态信息
                if (value)
                {
                    StatusMessage = "坐标模式已启用 - 在图片上移动鼠标查看坐标,点击选择坐标";
                }
                else
                {
                    StatusMessage = "坐标模式已禁用";
                    // 重置当前坐标显示
                    CurrentCoordinate.X = 0;
                    CurrentCoordinate.Y = 0;
                }
            }
        }

        public string StatusMessage
        {
            get => _statusMessage;
            set
            {
                _statusMessage = value;
                OnPropertyChanged();
            }
        }

        public CoordinateModel CurrentCoordinate
        {
            get => _currentCoordinate;
            set
            {
                _currentCoordinate = value;
                OnPropertyChanged();
            }
        }

        public CoordinateModel SelectedCoordinate
        {
            get => _selectedCoordinate;
            set
            {
                _selectedCoordinate = value;
                OnPropertyChanged();
            }
        }

需要注意几点。

撤销操作,我们第一时间想到的可能是用栈stack,但可能不太好实现mvvm,所以我就使用了ObservableCollection,同时实现了类似于stack的Push和Pop方法,并将集合的最后一个元素抽出来单独给xaml上的Image进行绑定。

水漫操作的填充颜色,颜色选择框使用的是Winform的ColorDialog,需要单独引用Winform库,其返回值类型是System.Drawing.Color,而CV2.FloodFill 使用的颜色是 Opevcvsharp.Scalar ,xaml上使用的Background是System.Windows.Media.Brush。所以我在vm中定义了一个 System.Windows.Media.Color 类型的属性,在选择颜色时可以通过 System.Windows.Media.Color.FromArgb 将 System.Drawing.Color转化成 System.Windows.Media.Color;在xaml绑定的时候可以使用 new SolidColorBrush,将System.Windows.Media.Color转换成 System.Windows.Media.Brush;在CV2.FloodFill的时候 通过 Scalar.FromRgb 可以将 System.Windows.Media.Color转化成 Opevcvsharp.Scalar。

CoordinateModel 类如下

        public class CoordinateModel : ViewModelBase
    {
        private double _x;
        private double _y;
        private bool _isSelected;

        public double X
        {
            get => _x;
            set
            {
                _x = value;
                OnPropertyChanged();
            }
        }

        public double Y
        {
            get => _y;
            set
            {
                _y = value;
                OnPropertyChanged();
            }
        }

        public bool IsSelected
        {
            get => _isSelected;
            set
            {
                _isSelected = value;
                OnPropertyChanged();
            }
        }
    }

同时注意坐标的转化,Image控件中的 Stretch="Fill",我们鼠标选中的坐标是相对于的Image控件的坐标,而不是图像的坐标,需要做个比例转化。 在viewmodel中定义以下方法

            /// <summary>
        /// 将控件坐标转换为图像坐标(Stretch="Fill")
        /// </summary>
        private System.Windows.Point ConvertToImageCoordinates(System.Windows.Point controlPosition, System.Windows.Size controlSize)
        {
            if (LastMat == null || controlSize.Width == 0 || controlSize.Height == 0)
                return new System.Windows.Point(0, 0);

            // 获取图像原始尺寸
            double imageWidth = LastMat.Width;
            double imageHeight = LastMat.Height;

            // Stretch="Fill" 的简单计算
            // 直接按比例映射
            double imageX = (controlPosition.X / controlSize.Width) * imageWidth;
            double imageY = (controlPosition.Y / controlSize.Height) * imageHeight;

            // 确保坐标在图像范围内
            imageX = Math.Max(0, Math.Min(imageX, imageWidth));
            imageY = Math.Max(0, Math.Min(imageY, imageHeight));

            return new System.Windows.Point(imageX, imageY);
        }

        // 处理鼠标移动
        public void HandleMouseMove(System.Windows.Point position, System.Windows.Size imageSize)
        {
            if (!IsCoordinateModeEnabled || LastMat == null) return;

            var imagePosition = ConvertToImageCoordinates(position, imageSize);
            CurrentCoordinate.X = Math.Round(imagePosition.X, 1);
            CurrentCoordinate.Y = Math.Round(imagePosition.Y, 1);

            //StatusMessage = $"当前坐标: X={CurrentCoordinate.X}, Y={CurrentCoordinate.Y}";
            StatusMessage = $"坐标模式开启中";
        }

        // 处理鼠标点击
        public void HandleMouseClick(System.Windows.Point position, System.Windows.Size imageSize)
        {
            if (!IsCoordinateModeEnabled || LastMat == null) return;

            var imagePosition = ConvertToImageCoordinates(position, imageSize);
            SelectedCoordinate.X = Math.Round(imagePosition.X, 1);
            SelectedCoordinate.Y = Math.Round(imagePosition.Y, 1);
            SelectedCoordinate.IsSelected = true;

            //  StatusMessage = $"已选择坐标: X={SelectedCoordinate.X}, Y={SelectedCoordinate.Y}";
            StatusMessage = $"已选择坐标";

            // 可选:短暂显示选中效果
            Task.Delay(1000).ContinueWith(_ =>
            {
                Application.Current.Dispatcher.Invoke(() =>
                {
                    SelectedCoordinate.IsSelected = false;
                });
            });
        }

同时需要在xaml.cs中将选中的坐标和Image控件的大小传给vm,代码如下

        /// <summary>
    /// FloodFillUserControl.xaml 的交互逻辑
    /// </summary>
    public partial class FloodFillUserControl : UserControl
    {
        private FloodFillViewModel _viewModel;

        public FloodFillUserControl()
        {
            InitializeComponent();
            _viewModel = new FloodFillViewModel();
            this.DataContext = _viewModel;
            TargetImage.Loaded += OnImageLoaded;
        }

        private void OnImageLoaded(object sender, RoutedEventArgs e)
        {
            var image = sender as Image;
            // 移除旧的事件处理
            image.MouseMove -= OnImageMouseMove;
            image.MouseLeftButtonDown -= OnImageMouseClick;
            // 添加新的事件处理
            image.MouseMove += OnImageMouseMove;
            image.MouseLeftButtonDown += OnImageMouseClick;
        }

        private void OnImageMouseMove(object sender, MouseEventArgs e)
        {
            if (_viewModel.IsCoordinateModeEnabled && _viewModel.LastMat != null)
            {
                Point position = e.GetPosition(TargetImage);
                var imageSize = new Size(TargetImage.ActualWidth, TargetImage.ActualHeight);
                _viewModel.HandleMouseMove(position, imageSize);
            }
        }

        private void OnImageMouseClick(object sender, MouseButtonEventArgs e)
        {
            if (_viewModel.IsCoordinateModeEnabled && _viewModel.LastMat != null)
            {
                var position = e.GetPosition(TargetImage);
                var controlSize = new Size(TargetImage.ActualWidth, TargetImage.ActualHeight);
                _viewModel.HandleMouseClick(position, controlSize);
            }
        }
    }

其余命令如下

            #region 水漫命令

        public ICommand FloodFillCommand { get; }

        private void ProcessFloodFill()
        {
            if (LastMat == null)
            {
                MessageBox.Show("未选择图像");
                return;
            }
            if (SelectedCoordinate.X == 0 || SelectedCoordinate.Y == 0)
            {
                MessageBox.Show("未选择坐标");
                return;
            }

            Mat srcImg = LastMat.Clone();
            Mat dstImg = new Mat();
            // 水漫图像需要 BGR三通道图片
            Cv2.CvtColor(srcImg, dstImg, ColorConversionCodes.BGRA2BGR);
            OpenCvSharp.Rect re;

            // 正负差最大值对应的颜色
            Scalar loDiffColor = new OpenCvSharp.Scalar(_downDiff, _downDiff, _downDiff);
            Scalar upDiffColor = new OpenCvSharp.Scalar(_upDiff, _upDiff, _upDiff);

            Scalar fillScalar = Scalar.FromRgb(_floodFillColor.R, _floodFillColor.G, _floodFillColor.B);

            OpenCvSharp.Point location = new OpenCvSharp.Point(_selectedCoordinate.X, _selectedCoordinate.Y);
            Cv2.FloodFill(dstImg, location, fillScalar, out re, loDiffColor, upDiffColor, FloodFillFlags.Link8);

            // 判断图像水漫前后是否有变化,有变化才加入栈
            if (MatEquall(srcImg, dstImg) == false)
            {
                Push(dstImg);
            }
        }

        // 判断两张多通道的图像是否相等
        private bool MatEquall(Mat m1, Mat m2)
        {
            if (m1.Empty() && m2.Empty())
            {
                return true;
            }
            if (m1.Cols != m2.Cols || m1.Rows != m2.Rows || m1.Dims != m2.Dims)
            {
                return false;
            }
            if (m1.Size() != m2.Size() || m1.Channels() != m2.Channels() || m1.Type() != m2.Type())
            {
                return false;
            }
            if (m1.Total() * m1.ElemSize() != m2.Total() * m2.ElemSize())
            {
                return false;
            }
            Mat tempMat = new Mat();
            Cv2.BitwiseXor(m1, m2, tempMat);

            Mat[] mm = Cv2.Split(tempMat);
            foreach (Mat mm1 in mm)
            {
                if (Cv2.CountNonZero(mm1) != 0)
                    return false;
            }
            return true;
        }

        #endregion 水漫命令

        #region 撤销命令

        public ICommand RevocationCommand { get; }

        private void ProcessRevocation()
        {
            Pop();
            IsCoordinateModeEnabled = false;
            SelectedCoordinate.X = 0;
            SelectedCoordinate.Y = 0;
            SelectedCoordinate.IsSelected = false;
            CurrentCoordinate.X = 0;
            CurrentCoordinate.Y = 0;
        }

        #endregion 撤销命令

        #region 复原命令

        public ICommand RecoverCommand { get; }

        private void Recover()
        {
            if (_matCollection.Count <= 0)
            {
                MessageBox.Show("未选择图像");
                return;
            }
            Mat mat = _matCollection[0];
            _matCollection.Clear();
            _matCollection.Add(mat);

            IsCoordinateModeEnabled = false;
            SelectedCoordinate.X = 0;
            SelectedCoordinate.Y = 0;
            SelectedCoordinate.IsSelected = false;
            CurrentCoordinate.X = 0;
            CurrentCoordinate.Y = 0;
        }

        #endregion 复原命令