WPF Image Pan, Zoom e Scroll com camadas em uma canvas

Espero que alguém possa me ajudar aqui. Eu estou construindo um aplicativo de imagem WPF que leva imagens ao vivo de uma câmera permitindo aos usuários visualizar a imagem e, posteriormente, destacar regiões de interesse (ROI) nessa imagem. As informações sobre as ROIs (largura, altura, localização relativa a um ponto da imagem, etc.) são enviadas de volta para a câmera, informando / treinando o firmware da câmera onde procurar por códigos de barras, texto, níveis de líquido, voltas num parafuso, etc. na imagem). Um recurso desejado é a capacidade de deslocar e ampliar a imagem e as ROIs, bem como rolar quando a imagem é ampliada com um zoom maior que a área de exibição. O StrokeThickness e o FontSize dos ROIs precisam manter a escala original, mas a largura e a altura das formas dentro de uma ROI precisam ser dimensionadas com a imagem (isso é crítico para capturar locais de pixel exatos para transmitir para a câmera). Eu tenho mais isso funcionou com a exceção de rolagem e alguns outros problemas. Minhas duas áreas de preocupação são:

  1. Quando introduzo um ScrollViewer, não obtenho nenhum comportamento de rolagem. Pelo que entendi, eu preciso introduzir um LayoutTransform para obter o comportamento correto do ScrollViewer. No entanto, quando faço isso, outras áreas começam a quebrar (por exemplo, as ROIs não mantêm a posição correta sobre a imagem ou o ponteiro do mouse começa a se afastar do ponto selecionado na imagem ou o canto esquerdo da imagem salta para a posição atual do mouse em MouseDown.)

  2. Eu não consigo obter a escala do meu ROI da maneira que eu preciso deles. Eu tenho este trabalho, mas não é ideal. O que eu tenho não retém a espessura exata do traço, e eu não olhei para ignorar escala nos blocos de texto. Espero que você veja o que estou fazendo nas amostras de código.

Tenho certeza de que meu problema tem algo a ver com minha falta de compreensão das Transformações e sua relação com o sistema de layout do WPF. Espero que uma versão do código que exibe o que eu realizei até agora ajude (veja abaixo).

FYI, se Adorners é a sugestão, que pode não funcionar no meu cenário, porque eu poderia acabar com mais adornos do que são suportados (rumores 144 adornos é quando as coisas começam a quebrar).

Primeiro, abaixo, uma captura de canvas mostrando uma imagem com o ROI (texto e uma forma). O retângulo, a elipse e o texto precisam seguir a área da imagem em escala e rotação, mas não devem ser dimensionados ou redimensionados.

Captura de tela mostrando a imagem de amostra com ROIs

Aqui está o XAML que está mostrando a imagem acima, junto com um Slider para zoom (o zoom da roda do mouse virá mais tarde)

                                  

Aqui está o C # que gerencia pan e zoom.

 public partial class MainWindow : Window { private Point origin; private Point start; private Slider _slider; public MainWindow() { this.InitializeComponent(); //Setup a transform group that we'll use to manage panning of the image area TransformGroup group = new TransformGroup(); ScaleTransform st = new ScaleTransform(); group.Children.Add(st); TranslateTransform tt = new TranslateTransform(); group.Children.Add(tt); //Wire up the slider to the image for zooming _slider = _ImageZoomSlider; _slider.ValueChanged += _ImageZoomSlider_ValueChanged; st.ScaleX = _slider.Value; st.ScaleY = _slider.Value; //_ImageScrollArea.RenderTransformOrigin = new Point(0.5, 0.5); //_ImageScrollArea.LayoutTransform = group; _DisplayImage.RenderTransformOrigin = new Point(0.5, 0.5); _DisplayImage.RenderTransform = group; _ROICollectionCanvas.RenderTransformOrigin = new Point(0.5, 0.5); _ROICollectionCanvas.RenderTransform = group; } //Captures the mouse to prepare for panning the scrollable image area private void ImageScrollArea_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) { _DisplayImage.ReleaseMouseCapture(); } //Moves/Pans the scrollable image area assuming mouse is captured. private void ImageScrollArea_MouseMove(object sender, MouseEventArgs e) { if (!_DisplayImage.IsMouseCaptured) return; var tt = (TranslateTransform)((TransformGroup)_DisplayImage.RenderTransform).Children.First(tr => tr is TranslateTransform); Vector v = start - e.GetPosition(border); tt.X = origin.X - vX; tt.Y = origin.Y - vY; } //Cleanup for Move/Pan when mouse is released private void ImageScrollArea_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { _DisplayImage.CaptureMouse(); var tt = (TranslateTransform)((TransformGroup)_DisplayImage.RenderTransform).Children.First(tr => tr is TranslateTransform); start = e.GetPosition(border); origin = new Point(tt.X, tt.Y); } //Zoom according to the slider changes private void _ImageZoomSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) { //Panel panel = _ImageScrollArea; Image panel = _DisplayImage; //Set the scale coordinates on the ScaleTransform from the slider ScaleTransform transform = (ScaleTransform)((TransformGroup)panel.RenderTransform).Children.First(tr => tr is ScaleTransform); transform.ScaleX = _slider.Value; transform.ScaleY = _slider.Value; //Set the zoom (this will affect rotate too) origin to the center of the panel panel.RenderTransformOrigin = new Point(0.5, 0.5); foreach (UIElement child in _ROICollectionCanvas.Children) { //Assume all shapes are contained in a panel Panel childPanel = child as Panel; var x = childPanel.Children; //Shape width and heigh should scale, but not StrokeThickness foreach (var shape in childPanel.Children.OfType()) { if (shape.Tag == null) { //Hack: This is be a property on a usercontrol in my solution shape.Tag = shape.StrokeThickness; } double orignalStrokeThickness = (double)shape.Tag; //Attempt to keep the underlying shape border/stroke from thickening as well double newThickness = shape.StrokeThickness - (orignalStrokeThickness / transform.ScaleX); shape.StrokeThickness -= newThickness; } } } } 

O código deve funcionar em um projeto e solução .NET 4.0 ou 4.5, assumindo que não há erros de recortar / colar.

Alguma ideia? Sugestões são bem vindas.

    Está bem. Esta é minha opinião sobre o que você descreveu.

    Se parece com isso:

    insira a descrição da imagem aqui

    • Como não estou aplicando nenhum RenderTransforms , obtenho a funcionalidade Scrollbar / ScrollViewer desejada.
    • MVVM, que é o caminho a percorrer no WPF. UI e dados são independentes, portanto, os DataItems têm apenas propriedades double e int para X, Y, Largura, Altura, etc, que você pode usar para qualquer finalidade ou até armazená-las em um Banco de Dados.
    • Eu adicionei todo o material dentro de um Thumb para lidar com o panning. Você ainda precisará fazer algo sobre o Panning que ocorre quando você está arrastando / redimensionando uma ROI através do ResizerControl. Eu acho que você pode verificar para Mouse.DirectlyOver ou algo assim.
    • Na verdade, usei um ListBox para lidar com as ROIs para que você possa ter 1 ROI selecionado a qualquer momento. Isso alterna a funcionalidade de redimensionamento. Então, se você clicar em um ROI, você verá o redimensionador visível.
    • O Scaling é tratado no nível ViewModel, eliminando assim a necessidade de Panels personalizados ou coisas assim (embora a solução da @Clemens seja boa também)
    • Estou usando um Enum e alguns DataTriggers para definir as formas. Consulte a parte DataTemplate DataType={x:Type local:ROI} .
    • Rochas do WPF. Apenas Copie e cole meu código em um File -> New Project -> WPF Application e veja os resultados por si mesmo.

                                                          

    Código por trás:

     public partial class PanZoomStackOverflow_MVVM : Window { public PanZoomViewModel ViewModel { get; set; } public PanZoomStackOverflow_MVVM() { InitializeComponent(); DataContext = ViewModel = new PanZoomViewModel(); ViewModel.ROIs.Add(new ROI() {ScaleFactor = ViewModel.ScaleFactor, X = 150, Y = 150, Height = 200, Width = 200, Shape = Shapes.Square}); ViewModel.ROIs.Add(new ROI() { ScaleFactor = ViewModel.ScaleFactor, X = 50, Y = 230, Height = 102, Width = 300, Shape = Shapes.Round }); } private void Thumb_DragDelta(object sender, DragDeltaEventArgs e) { //TODO: Detect whether a ROI is being resized / dragged and prevent Panning if so. IsPanning = true; ViewModel.OffsetX = (ViewModel.OffsetX + (((e.HorizontalChange/10) * -1) * ViewModel.ScaleFactor)); ViewModel.OffsetY = (ViewModel.OffsetY + (((e.VerticalChange/10) * -1) * ViewModel.ScaleFactor)); scr.ScrollToVerticalOffset(ViewModel.OffsetY); scr.ScrollToHorizontalOffset(ViewModel.OffsetX); IsPanning = false; } private bool IsPanning { get; set; } private void ScrollChanged(object sender, ScrollChangedEventArgs e) { if (!IsPanning) { ViewModel.OffsetX = e.HorizontalOffset; ViewModel.OffsetY = e.VerticalOffset; } } } 

    Main ViewModel:

     public class PanZoomViewModel:PropertyChangedBase { private double _offsetX; public double OffsetX { get { return _offsetX; } set { _offsetX = value; OnPropertyChanged("OffsetX"); } } private double _offsetY; public double OffsetY { get { return _offsetY; } set { _offsetY = value; OnPropertyChanged("OffsetY"); } } private double _scaleFactor = 1; public double ScaleFactor { get { return _scaleFactor; } set { _scaleFactor = value; OnPropertyChanged("ScaleFactor"); ROIs.ToList().ForEach(x => x.ScaleFactor = value); } } private ObservableCollection _rois; public ObservableCollection ROIs { get { return _rois ?? (_rois = new ObservableCollection()); } } } 

    ROI ViewModel:

     public class ROI:PropertyChangedBase { private Shapes _shape; public Shapes Shape { get { return _shape; } set { _shape = value; OnPropertyChanged("Shape"); } } private double _scaleFactor; public double ScaleFactor { get { return _scaleFactor; } set { _scaleFactor = value; OnPropertyChanged("ScaleFactor"); OnPropertyChanged("ActualX"); OnPropertyChanged("ActualY"); OnPropertyChanged("ActualHeight"); OnPropertyChanged("ActualWidth"); } } private double _x; public double X { get { return _x; } set { _x = value; OnPropertyChanged("X"); OnPropertyChanged("ActualX"); } } private double _y; public double Y { get { return _y; } set { _y = value; OnPropertyChanged("Y"); OnPropertyChanged("ActualY"); } } private double _height; public double Height { get { return _height; } set { _height = value; OnPropertyChanged("Height"); OnPropertyChanged("ActualHeight"); } } private double _width; public double Width { get { return _width; } set { _width = value; OnPropertyChanged("Width"); OnPropertyChanged("ActualWidth"); } } public double ActualX { get { return X*ScaleFactor; }} public double ActualY { get { return Y*ScaleFactor; }} public double ActualWidth { get { return Width*ScaleFactor; }} public double ActualHeight { get { return Height * ScaleFactor; } } } 

    Formas Enum:

     public enum Shapes { Round = 1, Square = 2, AnyOther } 

    PropertyChangedBase (class auxiliar do MVVM):

      public class PropertyChangedBase:INotifyPropertyChanged { public event PropertyChangedEventHandler PropertyChanged; protected virtual void OnPropertyChanged(string propertyName) { Application.Current.Dispatcher.BeginInvoke((Action) (() => { PropertyChangedEventHandler handler = PropertyChanged; if (handler != null) handler(this, new PropertyChangedEventArgs(propertyName)); })); } } 

    Controle Resizer:

              

    Código por trás:

      public partial class ResizerControl : UserControl { public static readonly DependencyProperty XProperty = DependencyProperty.Register("X", typeof(double), typeof(ResizerControl), new FrameworkPropertyMetadata(0d,FrameworkPropertyMetadataOptions.BindsTwoWayByDefault)); public static readonly DependencyProperty YProperty = DependencyProperty.Register("Y", typeof(double), typeof(ResizerControl), new FrameworkPropertyMetadata(0d, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault)); public static readonly DependencyProperty ItemWidthProperty = DependencyProperty.Register("ItemWidth", typeof(double), typeof(ResizerControl), new FrameworkPropertyMetadata(0d, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault)); public static readonly DependencyProperty ItemHeightProperty = DependencyProperty.Register("ItemHeight", typeof(double), typeof(ResizerControl), new FrameworkPropertyMetadata(0d, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault)); public double X { get { return (double) GetValue(XProperty); } set { SetValue(XProperty, value); } } public double Y { get { return (double)GetValue(YProperty); } set { SetValue(YProperty, value); } } public double ItemHeight { get { return (double) GetValue(ItemHeightProperty); } set { SetValue(ItemHeightProperty, value); } } public double ItemWidth { get { return (double) GetValue(ItemWidthProperty); } set { SetValue(ItemWidthProperty, value); } } public ResizerControl() { InitializeComponent(); } private void UpperLeft_DragDelta(object sender, DragDeltaEventArgs e) { X = X + e.HorizontalChange; Y = Y + e.VerticalChange; ItemHeight = ItemHeight + e.VerticalChange * -1; ItemWidth = ItemWidth + e.HorizontalChange * -1; } private void UpperRight_DragDelta(object sender, DragDeltaEventArgs e) { Y = Y + e.VerticalChange; ItemHeight = ItemHeight + e.VerticalChange * -1; ItemWidth = ItemWidth + e.HorizontalChange; } private void LowerLeft_DragDelta(object sender, DragDeltaEventArgs e) { X = X + e.HorizontalChange; ItemHeight = ItemHeight + e.VerticalChange; ItemWidth = ItemWidth + e.HorizontalChange * -1; } private void LowerRight_DragDelta(object sender, DragDeltaEventArgs e) { ItemHeight = ItemHeight + e.VerticalChange; ItemWidth = ItemWidth + e.HorizontalChange; } private void Center_DragDelta(object sender, DragDeltaEventArgs e) { X = X + e.HorizontalChange; Y = Y + e.VerticalChange; } } 

    Para transformar formas sem alterar sua espessura de traçado, você pode usar objects Path com geometrys transformadas.

    O seguinte XAML coloca uma imagem e dois caminhos em uma canvas. A imagem é dimensionada e traduzida por um RenderTransform. A mesma transformação também é usada para a propriedade Transform das geometrys dos dois caminhos.

                         

    Seu aplicativo pode agora simplesmente alterar o object de transform em resposta a events de input como MouseMove ou MouseWheel.

    As coisas ficam um pouco mais complicadas quando se trata de transformar TextBlocks ou outro elemento que não deve ser dimensionado, mas apenas ser movido para um local apropriado.

    Você pode criar um painel especializado capaz de aplicar esse tipo de transformação a seus elementos filho. Esse painel definiria uma propriedade anexada que controla a posição de um elemento filho e aplicaria a transformação a essa posição em vez de LayoutTransform ou LayoutTransform do filho.

    Isso pode lhe dar uma idéia de como tal painel poderia ser implementado:

     public class TransformPanel : Panel { public static readonly DependencyProperty TransformProperty = DependencyProperty.Register( "Transform", typeof(Transform), typeof(TransformPanel), new FrameworkPropertyMetadata(Transform.Identity, FrameworkPropertyMetadataOptions.AffectsArrange)); public static readonly DependencyProperty PositionProperty = DependencyProperty.RegisterAttached( "Position", typeof(Point?), typeof(TransformPanel), new PropertyMetadata(PositionPropertyChanged)); public Transform Transform { get { return (Transform)GetValue(TransformProperty); } set { SetValue(TransformProperty, value); } } public static Point? GetPosition(UIElement element) { return (Point?)element.GetValue(PositionProperty); } public static void SetPosition(UIElement element, Point? value) { element.SetValue(PositionProperty, value); } protected override Size MeasureOverride(Size availableSize) { var infiniteSize = new Size(double.PositiveInfinity, double.PositiveInfinity); foreach (UIElement element in InternalChildren) { element.Measure(infiniteSize); } return new Size(); } protected override Size ArrangeOverride(Size finalSize) { foreach (UIElement element in InternalChildren) { ArrangeElement(element, GetPosition(element)); } return finalSize; } private void ArrangeElement(UIElement element, Point? position) { var arrangeRect = new Rect(element.DesiredSize); if (position.HasValue && Transform != null) { arrangeRect.Location = Transform.Transform(position.Value); } element.Arrange(arrangeRect); } private static void PositionPropertyChanged( DependencyObject obj, DependencyPropertyChangedEventArgs e) { var element = (UIElement)obj; var panel = VisualTreeHelper.GetParent(element) as TransformPanel; if (panel != null) { panel.ArrangeElement(element, (Point?)e.NewValue); } } } 

    Seria usado em XAML assim: