0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TransformGroupを活用したWPFライブビューのズーム/パン対応【 C# / .NET 8】

Posted at

TransformGroupを活用したWPFライブビューのズーム/パン対応【 C# / .NET 8】

前回は HUD(FPS・露光・ゲイン) を映像に重ねる最小構成を作りました。
今回はその続編として、ズーム/パン(マウス操作)ROI(画像に追従する矩形)クロスヘア(画面中央固定) を追加します。
レイヤー分離の設計(映像=拡大縮小、HUD=固定)を引き継ぎ、にじみ・揺れの少ない表示を目指します。


使用環境 / 前提


ゴール

  • マウスホイールでカーソル位置基準のズーム(倍率は 1.1 倍/段)
  • Shift+画像ドラッグでパン(平行移動)
  • ROI矩形は“画像と一緒に拡大縮小”(追従)
  • クロスヘアは“画面座標に固定”(HUDレイヤ)

XAML:二層+ROIレイヤ(Transform共有)

実装量が増えてきたので、適宜省略します。

<Window x:Class="BaslerGUISample.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:BaslerGUISample"
        xmlns:models="clr-namespace:BaslerGUISample.Models"
        mc:Ignorable="d"
        Title="BaslerGUISample" Height="450" Width="500" Closing="Window_Closing">

    <Window.Resources>
        <!-- 画像ズーム/パン用の共有Transform(Image と ROI で共用) -->
        <TransformGroup x:Key="ContentTransform" Changed="TransformGroup_Changed">
            <ScaleTransform x:Name="Zoom" ScaleX="1" ScaleY="1"/>
            <TranslateTransform x:Name="Pan" X="0" Y="0"/>
        </TransformGroup>
        <models:HalfConverter x:Key="Half"/>
    </Window.Resources>
    <Grid>  
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>

        <!-- Row=0はこれまでと同じ -->

        <!-- 拡大縮小操作 -->
        <Grid Grid.Row="1" Grid.Column="0">
            <Button Content="Fit" Width="80" Margin="0,5" Click="Fit_Click"/>
        </Grid>
        <Grid Grid.Row="1" Grid.Column="1">
            <Button Content="1:1" Width="80" Margin="0,5" Click="Pixel1to1_Click"/>
        </Grid>
        <Grid Grid.Row="1" Grid.Column="2">
            <Button Content="Reset" Width="80" Margin="0,5" Click="Reset_Click"/>
        </Grid>
        <Grid Grid.Row="1" Grid.Column="3">
            <TextBlock Text="{Binding ZoomText, RelativeSource={RelativeSource AncestorType=local:MainWindow}}" VerticalAlignment="Center" Margin="8,0,0,0"/>
        </Grid>

        <!-- 表示領域 -->
        <Grid Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="5"
              ClipToBounds="True"
              UseLayoutRounding="True"
              SnapsToDevicePixels="True"
              TextOptions.TextFormattingMode="Display"
              TextOptions.TextRenderingMode="ClearType"
              PreviewMouseWheel="Root_PreviewMouseWheel"
              MouseLeftButtonDown="Root_MouseDown"
              MouseMove="Root_MouseMove"
              MouseUp="Root_MouseUp">

            <!-- 映像レイヤ(ズーム/パン適用) -->
            <Canvas x:Name="ImageLayer">
                <Image x:Name="CameraImage"
                       Source="{Binding CurrentFrame}"
                       RenderOptions.BitmapScalingMode="NearestNeighbor"
                       Stretch="Uniform"
                       RenderTransform="{StaticResource ContentTransform}"/>
            </Canvas>

            <!-- ROIレイヤ(Transform共有で画像に追従) -->
            <Canvas x:Name="RoiLayer"
                    IsHitTestVisible="False"
                    RenderTransform="{StaticResource ContentTransform}">
                <Rectangle x:Name="RoiRect" Width="270" Height="200"
                        Canvas.Left="100" Canvas.Top="130"
                        Stroke="Lime" StrokeThickness="1"
                        StrokeDashArray="4,2" />
            </Canvas>

            <!-- HUDレイヤ(ズーム非追従・画面固定) -->
            <Grid x:Name="HudLayer" IsHitTestVisible="False">
                <!-- 左上HUDボックスは前回と同じ -->

                <!-- 画面中央クロスヘア -->
                <Line X1="0"
                    Y1="{Binding ActualHeight, ElementName=HudLayer, Converter={StaticResource Half}}"
                    X2="{Binding ActualWidth,  ElementName=HudLayer}"
                    Y2="{Binding ActualHeight, ElementName=HudLayer, Converter={StaticResource Half}}"
                    Stroke="White" StrokeThickness="1" StrokeDashArray="2,2" Opacity="0.6"/>
                <Line X1="{Binding ActualWidth, ElementName=HudLayer, Converter={StaticResource Half}}"
                    Y1="0"
                    X2="{Binding ActualWidth, ElementName=HudLayer, Converter={StaticResource Half}}"
                    Y2="{Binding ActualHeight, ElementName=HudLayer}"
                    Stroke="White" StrokeThickness="1" StrokeDashArray="2,2" Opacity="0.6"/>
                
                <!-- ここにHUD要素を足していく -->
            </Grid>
        </Grid>
    </Grid>
</Window>

HalfConverter(クロスヘア中央用)

HUDの中心に十字を引き、アライメントしやすくします。View側で処理できるようにConverterを用意しておきます。

using System;
using System.Globalization;
using System.Windows.Data;

namespace BaslerGUISample.Models
{
    public class HalfConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
            => value is double d ? d / 2.0 : 0.0;

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
            => Binding.DoNothing;
    }
}

コードビハインド:ズーム/パン

今回はコードビハインドだけで拡大縮小を実装します。
必要に応じて、ViewModel、Modelを活用していきます。

using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
using BaslerGUISample.ViewModels;

namespace BaslerGUISample
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        private readonly MainViewModel _viewModel;

        private TransformGroup? _contentTransform;
        private ScaleTransform? _scale;
        private TranslateTransform? _trans;

        private bool _panning;
        private Point _panStart;

        public MainWindow()
        {
            InitializeComponent();
            _viewModel = new MainViewModel();
            DataContext = _viewModel;
            _contentTransform = (TransformGroup)Resources["ContentTransform"];
            _scale = (ScaleTransform)_contentTransform.Children[0];
            _trans = (TranslateTransform)_contentTransform.Children[1];
            UpdateZoomText();
        }

        public string ZoomText
        {
            get => (string)GetValue(ZoomTextProperty);
            set => SetValue(ZoomTextProperty, value);
        }
        public static readonly DependencyProperty ZoomTextProperty =
            DependencyProperty.Register(nameof(ZoomText), typeof(string), typeof(MainWindow),
                new PropertyMetadata("Zoom: 1.00x"));

        /// <summary>
        /// ウィンドウが閉じられる前にカメラ接続を切断します。 
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
        {
            if (_viewModel.IsGrabbing)
            {
                _viewModel.Stop();
            }
            _viewModel.Disconnect();
        }
        
        private void Root_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
        {
            const double step = 1.1;
            double factor = e.Delta > 0 ? step : 1.0 / step;

            var viewPt    = e.GetPosition(ImageLayer);
            var contentPt = ToContent(viewPt);

            double newScaleX = _scale!.ScaleX * factor;
            double newScaleY = _scale!.ScaleY * factor;
            newScaleX = Math.Clamp(newScaleX, 0.1, 20.0);
            newScaleY = Math.Clamp(newScaleY, 0.1, 20.0);
            _scale.ScaleX = newScaleX;
            _scale.ScaleY = newScaleY;

            var post = _contentTransform!.Transform(contentPt);
            _trans!.X += viewPt.X - post.X;
            _trans!.Y += viewPt.Y - post.Y;

        }

        private void Root_MouseDown(object sender, MouseButtonEventArgs e)
        {
            if (e.MiddleButton == MouseButtonState.Pressed || (e.LeftButton == MouseButtonState.Pressed && (Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift))))
            {
                _panning = true;
                _panStart = e.GetPosition(ImageLayer);
                Mouse.Capture(ImageLayer);
            }
        }

        private void Root_MouseMove(object sender, MouseEventArgs e)
        {
            if (!_panning) return;
            var now = e.GetPosition(ImageLayer);
            var delta = now - _panStart;
            _panStart = now;
            _trans!.X += delta.X;
             _trans!.Y += delta.Y;
        }

        private void Root_MouseUp(object sender, MouseButtonEventArgs e)
        {
            _panning = false;
            Mouse.Capture(null);
        }

        private void Reset_Click(object sender, RoutedEventArgs e)
        {
            _scale!.ScaleX = _scale.ScaleY = 1.0;
            _trans!.X = _trans.Y = 0.0;
        }

        private void Pixel1to1_Click(object sender, RoutedEventArgs e)
        {
            _scale!.ScaleX = _scale.ScaleY = 1.0;
        }

        private void Fit_Click(object sender, RoutedEventArgs e)
        {
            if (_viewModel.CurrentFrame is null)
                return;
            var w = ImageLayer.ActualWidth;
            var h = ImageLayer.ActualHeight;
            if (w <= 0 || h <= 0) return;

            double srcW = _viewModel.CurrentFrame.PixelWidth, srcH = _viewModel.CurrentFrame.PixelHeight;
            double s = Math.Min(w / srcW, h / srcH);
            _scale!.ScaleX = _scale.ScaleY = s;
            _trans!.X = (w - srcW * s) * 0.5;
            _trans!.Y = (h - srcH * s) * 0.5;
        }

        private void UpdateZoomText()
        {
            ZoomText = $"Zoom: {_scale?.ScaleX:0.00}x";
        }

        private Point ToContent(Point viewPt)
        {
            var inv = _contentTransform!.Inverse;
            return inv.Transform(viewPt);
        }

        private void TransformGroup_Changed(object sender, EventArgs e)
        {
            UpdateZoomText();
        }
    }
}

ROIの扱いと実アプリへの反映

上記のサンプルではTransformGroupを設定し、ImageRoiLayerに適用しています。

  • RoiLayer は画像と同じ Transform を共有しているため、ズーム/パン操作に自然に追従します
  • 実務では、RoiRect の Canvas.Left/Top/Width/Height を VM にバインドし、カメラの ROI パラメータへ適用します
  • カメラへROI設定を行う場合はGenICam の制約(Inc 値)に注意してください

一方で HUD(クロスヘアや文字)は Transform を適用せず、画面座標に固定することで にじみを防ぎ、操作性も保つことができます。


実行例

以下のような画面になります。

ROIZoom_Default.png

Shiftを押しながらライブビューをドラッグするとパン。ROIは追従します。クロスヘアは固定です。

ROIZoom_Trans.png

マウスホイールでズーム。右上の現在の拡大率も変更されています。

ROIZoom_Zoom.png

Fitボタンを押すと画面に収まるようにズームします。1:1ボタンを押すと拡大率が1になり、Resetボタンで拡大率を1、移動量を0に戻します。

ROIZoom_Fit.png


まとめ

  • 映像+ROIはTransform共有、HUDは固定のレイヤ設計
  • マウス操作で カーソル中心ズーム と 直感的なパン
  • ROIは画像と追従、クロスヘアは固定

👨‍💻 筆者について

@MilleVision
産業用カメラ・画像処理システムの開発に関する情報を発信中。
pylon SDK × C# の活用シリーズを連載しています。


🛠 サンプルコード完全版のご案内

Qiita記事のサンプルをまとめた C#プロジェクト(単体テスト付き) を BOOTH で配布しています。

  • 撮影・露光・ゲイン・フレームレート・ROI・イベント駆動撮影など主要機能を網羅
  • WPFへの実装もフォロー
  • 単体テスト同梱で動作確認や学習がスムーズ
  • 記事更新に合わせてアップデート予定

👉 商品ページはこちら

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?