TransformGroupを活用したWPFライブビューのズーム/パン対応【 C# / .NET 8】
前回は HUD(FPS・露光・ゲイン) を映像に重ねる最小構成を作りました。
今回はその続編として、ズーム/パン(マウス操作)、ROI(画像に追従する矩形)、クロスヘア(画面中央固定) を追加します。
レイヤー分離の設計(映像=拡大縮小、HUD=固定)を引き継ぎ、にじみ・揺れの少ない表示を目指します。
使用環境 / 前提
- Basler pylon Camera Software Suite(
Basler.Pylon
参照済み) - .NET 8 / WPF(Windowsデスクトップ)
- 前回記事(WPFでBaslerライブビューにHUDを重ねる【FPS・露光・ゲイン/二層レイアウト】)までの内容
- 既存のライブビュー(
CurrentFrame: ImageSource
をImage
にバインド) - 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
を設定し、Image
とRoiLayer
に適用しています。
- RoiLayer は画像と同じ Transform を共有しているため、ズーム/パン操作に自然に追従します
- 実務では、RoiRect の Canvas.Left/Top/Width/Height を VM にバインドし、カメラの ROI パラメータへ適用します
- カメラへROI設定を行う場合はGenICam の制約(Inc 値)に注意してください
一方で HUD(クロスヘアや文字)は Transform を適用せず、画面座標に固定することで にじみを防ぎ、操作性も保つことができます。
実行例
以下のような画面になります。
Shiftを押しながらライブビューをドラッグするとパン。ROIは追従します。クロスヘアは固定です。
マウスホイールでズーム。右上の現在の拡大率も変更されています。
Fit
ボタンを押すと画面に収まるようにズームします。1:1
ボタンを押すと拡大率が1になり、Resetボタンで拡大率を1、移動量を0に戻します。
まとめ
- 映像+ROIはTransform共有、HUDは固定のレイヤ設計
- マウス操作で カーソル中心ズーム と 直感的なパン
- ROIは画像と追従、クロスヘアは固定
👨💻 筆者について
@MilleVision
産業用カメラ・画像処理システムの開発に関する情報を発信中。
pylon SDK × C# の活用シリーズを連載しています。
🛠 サンプルコード完全版のご案内
Qiita記事のサンプルをまとめた C#プロジェクト(単体テスト付き) を BOOTH で配布しています。
- 撮影・露光・ゲイン・フレームレート・ROI・イベント駆動撮影など主要機能を網羅
- WPFへの実装もフォロー
- 単体テスト同梱で動作確認や学習がスムーズ
- 記事更新に合わせてアップデート予定