WPF 的深度缩放






4.97/5 (54投票s)
WPF 的 MultiScaleImage(Deep Zoom)实现,兼容 Deep Zoom Composer 和 Zoom.it。
- 下载演示项目 - 51.7 KB
- 下载源代码 - 64.5 KB
- 最新版本可在 CodePlex 上找到 [^]
目录
引言
Silverlight 最具“魔力”的功能之一是 Deep Zoom [^]。通过巧妙地分割图像,Deep Zoom 允许用户以出色的性能流畅地平移和缩放巨大的(甚至可能是无限的)图像。
自 Silverlight 2 发布 Deep Zoom 以来,它一直是 WPF 中最受请求的功能之一——截至撰写本文时,它在 WPF 功能建议的 UserVoice 网站上排名前 10 [^]。
好了,你们的请求得到了回应!本文介绍了一个功能齐全的 WPF Deep Zoom 实现,包括 Deep Zoom Composer 和 Zoom.it 支持、多点触控支持等。第一部分,我们将了解如何使用该控件及其当前限制;之后,我们将详细了解其实现方式。让我们开始吧!
使用控件
Deep Zoom for WPF 项目是一个在 CodePlex [^] 上以 MS-PL [^] 许可证提供的开源项目。您可以在 http://deepzoom.codeplex.com/releases [^] 下载最新版本。
要使用该控件,您只需添加对 DeepZoom.dll 的引用,并像普通图像一样将其添加到 XAML 文件中。
<Window x:Class="DeepZoom.TestApplication.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:dz="clr-namespace:DeepZoom.Controls;assembly=DeepZoom"
Title="MainWindow" Height="800" Width="1280">
<Border Background="Black">
<dz:MultiScaleImage
Source="http://static.seadragon.com/content/misc/color-flower.dzi" />
</Border>
</Window>
再简单不过了 :)
该控件还支持使用 Deep Zoom Composer [^](**集合除外**)和 Zoom.it [^] 生成的图像,以及自定义图块源。
限制和已知问题
该控件的当前实现是 Silverlight MultiScaleImage
的简化版本,仅支持其最基本的功能。其限制和已知问题如下:
- 该控件仅支持 Deep Zoom 图像(包括稀疏图像),**尚不支持 Deep Zoom 集合**。
- 不支持的 Silverlight 成员
- 属性:
AllowDownloading
BlurFactor
IsDownloading
IsIdle
SkipLevels
SubImages
UseSprings
ViewportOrigin
ViewportWidth
- 事件
ImageFailed
ImageOpenFailed
ImageOpenSucceeded
MotionFinished
ViewportChanged
- 该控件仅支持通过鼠标拖动/滚轮和多点触控进行平移和缩放,**没有额外的导航按钮**(但可以轻松添加)。
- 图块的最大数量(总计)为 **
int.MaxValue
(约 21 亿)**。这是因为使用了IList/ItemsContainerGenerator
进行虚拟化(稍后详述)。因此,最大缩放级别将为约 24(取决于图像的纵横比)。 - 在级别约 21 之后,平移和缩放性能会变差。如果您有任何修复此问题的想法,请告诉我!
致谢
本项目使用了以下库的代码:
- Kael Rowan 的 ZoomableCanvas 和 MathExtensions
- Josh Blake 的 Blake.NUI
特别感谢 IntuiLab,我项目诞生时的雇主,允许我将此项目和文章作为开源发布!
看看它是如何实现的
Deep Zoom 是如何工作的?
构建 Deep Zoom 的第一步是理解它的工作原理。摘自 Jaime Rodriguez 的 Deep Zoom Primer [^]
Deep Zoom 通过将图像(或图像组合)分割成图块来实现其目标。在对图像进行分块的同时,组合器还会为原始组合创建低分辨率图块的层级结构。
图片来自 MSDN Library
上图展示了层级结构的样子;原始图像位于层级结构的底部,请注意它如何被分割成更小的图像;另请注意,层级结构会创建分辨率更低的图像(也已分块)。(...) 所有这些分块操作都在设计时完成,并通过 Deep Zoom Composer 实现。
在运行时,
MultiScaleImage
首先下载图像的低分辨率图块,然后在需要时(在平移或缩放时)按需下载其他图像;Deep Zoom 可确保从低分辨率到高分辨率图像的过渡平滑无缝。
在 Deep Zoom 中,所有图块都是正方形且大小相同(默认 256x256 像素)。尽管如此,如果您查看计算机上 Deep Zoom 项目的输出文件夹,您会发现图块的大小不同,因为它们还会相互重叠(默认情况下,每边重叠 1 像素)。
本文将不深入探讨 Deep Zoom 的数学细节 - 请参考 Daniel Gasienica 博客上的 “Inside Deep Zoom - Part II: Mathematical Analysis” [^],以从数学角度对多尺度图像进行非常有趣的解释。
Deep Zoom 对象模型
为了尽可能贴近 Silverlight 的 Deep Zoom 对象模型,本项目提供了一个模仿 Silverlight 的类库。公共对象模型表示如下:
唯一的区别(除了未实现的成员)是 GetTileLayers
方法已被简化。在原始 Silverlight 版本中,该方法具有以下签名:
protected abstract void GetTileLayers(int tileLevel,
int tilePositionX,
int tilePositionY,
IList<object> tileImageLayerSources);
实现此方法的类必须添加新图块的 Uri
到 tileImageLayerSources
列表中。由于该 URI 列表在此项目中未使用,因此该方法具有以下签名:
protected abstract object GetTileLayers(int tileLevel,
int tilePositionX,
int tilePositionY);
该方法现在支持 Uri
和 Stream
作为有效返回值,允许自定义图块源在内存中动态生成图块。
进入 ZoomableCanvas
由于多尺度图像可能包含数十亿个图块,Deep Zoom 的主要挑战在于虚拟化。我们需要从两个方面进行虚拟化:
- **数据**必须虚拟化,以便只将正在使用的图块存储在内存中。
- **UI** 必须虚拟化,以便我们只在需要时加载和渲染图像。
为了实现这两种虚拟化,我使用了 Kael Rowan 的 ZoomableCanvas [^]。这个巧妙的控件允许我们在平移和缩放时进行项目虚拟化。基本思想是,在任何时刻,ZoomableCanvas
都必须能够知道当前视图框中哪些元素可见,以便它可以适当地加载和“实现”这些元素。
当您创建一个实现名为 ISpatialItemsSource
的接口的类时,就会发生奇迹:
public interface ISpatialItemsSource
{
Rect Extent { get; }
event EventHandler ExtentChanged;
event EventHandler QueryInvalidated;
IEnumerable<int> Query(Rect rectangle);
}
上述 Query
方法应返回在作为参数传入的矩形内的可见元素的索引列表,而 Extent
属性表示画布的边界。此外,您的类应使用 ExtentChanged
和 QueryInvalidated
事件来通知画布它应重新查询或更改其范围(通常在项目集合更改时)。
当此接口实现后,ZoomableCanvas
通过在视图更改时(通过平移或缩放)查询可见项目来管理要添加到屏幕和从屏幕移除的元素。然后,如果 ZoomableCanvas
用作 ItemsControl
的 ItemsPanel
(这意味着 ItemsSource
同时实现了 ISpatialItemsSource
和 IList
),画布将按索引查询 List 以获取项目并将其显示在屏幕上。
由于 IList
的这种依赖关系,ZoomableCanvas
仅限于 int.MaxValue
(约 21 亿)个图块,这似乎是一个很大的数字,但一些大型 Deep Zoom 稀疏图像可能会超过这个数量。
本项目中使用的实现位于 MultiScaleImageSpatialItemsSource
类中,该类基于 Kael Rowan 的名为 “ZoomableApplication2 - A million items” [^] 的演示。在此演示中,Kael 使用了一个简单的算法来确定均匀网格中可见的图块,并按从可见区域中心到边缘的顺序排列。本项目中的实现如下:
private IEnumerable<Tile> VisibleTiles(Rect rectangle, int level)
{
rectangle.Intersect(new Rect(ImageSize));
var top = Math.Floor(rectangle.Top / TileSize);
var left = Math.Floor(rectangle.Left / TileSize);
var right = Math.Ceiling(rectangle.Right / TileSize);
var bottom = Math.Ceiling(rectangle.Bottom / TileSize);
right = right.AtMost(ColumnsAtLevel(level));
bottom = bottom.AtMost(RowsAtLevel(level));
var width = (right - left).AtLeast(0);
var height = (bottom - top).AtLeast(0);
if (top == 0.0 && left == 0.0 && width == 1.0 && height == 1.0)
// This level only has one tile
yield return new Tile(level, 0, 0);
else
{
foreach (var pt in Quadivide(new Rect(left, top, width, height)))
yield return new Tile(level, (int)pt.X, (int)pt.Y);
}
}
private static IEnumerable<Point> Quadivide(Rect area)
{
if (area.Width > 0 && area.Height > 0)
{
var center = area.GetCenter();
var x = Math.Floor(center.X);
var y = Math.Floor(center.Y);
yield return new Point(x, y);
var quad1 = new Rect(area.TopLeft, new Point(x, y + 1));
var quad2 = new Rect(area.TopRight, new Point(x, y));
var quad3 = new Rect(area.BottomLeft, new Point(x + 1, y + 1));
var quad4 = new Rect(area.BottomRight, new Point(x + 1, y));
var quads = new Queue<IEnumerator<Point>>();
quads.Enqueue(Quadivide(quad1).GetEnumerator());
quads.Enqueue(Quadivide(quad2).GetEnumerator());
quads.Enqueue(Quadivide(quad3).GetEnumerator());
quads.Enqueue(Quadivide(quad4).GetEnumerator());
while (quads.Count > 0)
{
var quad = quads.Dequeue();
if (quad.MoveNext())
{
yield return quad.Current;
quads.Enqueue(quad);
}
}
}
}
一些需要注意的点:
Tile
结构通过其级别(Level)、列(Column)和行(Row)(均为int
类型)来标识一个图块。Quadivide
算法只是将一个矩形分割成 1x1 的“单元格”,并按从中心到边缘的顺序排列。- 此代码使用 Kael Rowan 的一些 MathExtensions [^] 扩展方法,例如
AtMost
和AtLeast
,使代码更具可读性。
需要注意的一个重要点是,此算法必须在每个级别(从 0 到当前级别)上运行,才能实现 Deep Zoom 的“渐进加载”效果。执行此操作的代码如下:
internal IEnumerable<Tile> VisibleTilesUntilFill(Rect rectangle, int startingLevel)
{
var levels = Enumerable.Range(0, startingLevel + 1);
return levels.SelectMany(level =>
{
var levelScale = ScaleAtLevel(level);
var scaledBounds = new Rect(rectangle.X * levelScale,
rectangle.Y * levelScale,
rectangle.Width * levelScale,
rectangle.Height * levelScale);
return VisibleTiles(scaledBounds, level);
});
}
此 LINQ 查询采用坐标系为完整图像的可见矩形(由 ISpatialItemsSource
Query
方法传入),将其缩放到每个级别,并通过上述算法计算其中的单元格。请注意,随着 startingLevel
增加(用户正在深入缩放图像),此算法会变慢。根据 Daniel Gasienica 的文章 [^],此算法下载的数据量比仅显示最大层所需的数据量多 33%。
显示图块
知道了需要显示哪些图块后,我们就需要按需下载它们。为此,MultiScaleImageSpatialItemsSource
对 IList
的 this[int index]
实现必须能够从其索引获取图块,从 MultiScaleTileSource
获取图像源,并下载图像。在此实现中,下载使用 .NET 并行扩展异步完成。该类还管理图块的本地缓存。
下载后,我们必须以合理的性能将图块显示在屏幕上。由于屏幕上最多会显示 200 个图块,并且图块不需要交互,因此不建议使用 Image
对象或其他复杂的交互式 FrameworkElement
。
本项目中实现的解决方案是一个继承自 FrameworkElement
的简单类,它在 Visual
对象中渲染图像:
public class TileHost : FrameworkElement
{
private DrawingVisual _visual;
private static readonly AnimationTimeline _opacityAnimation =
new DoubleAnimation(1, TimeSpan.FromMilliseconds(500))
{ EasingFunction = new ExponentialEase() };
public TileHost()
{
IsHitTestVisible = false;
}
// Both dependency properties trigger the RefreshTile callback when changed
public static readonly DependencyProperty SourceProperty =
DependencyProperty.Register("Source",
typeof(ImageSource),
typeof(TileHost),
new FrameworkPropertyMetadata(null,
new PropertyChangedCallback(RefreshTile)));
public static readonly DependencyProperty ScaleProperty =
DependencyProperty.Register("Scale",
typeof(double),
typeof(TileHost),
new FrameworkPropertyMetadata(1.0,
new PropertyChangedCallback(RefreshTile)));
private static void RefreshTile(DependencyObject d,
DependencyPropertyChangedEventArgs e)
{
var tileHost = d as TileHost;
if (tileHost != null && tileHost.Source != null && tileHost.Scale > 0)
tileHost.RenderTile();
}
private void RenderTile()
{
_visual = new DrawingVisual();
Width = Source.Width * Scale;
Height = Source.Height * Scale;
var dc = _visual.RenderOpen();
dc.DrawImage(Source, new Rect(0, 0, Width, Height));
dc.Close();
CacheMode = new BitmapCache(1 / Scale);
// Animate opacity
Opacity = 0;
BeginAnimation(OpacityProperty, _opacityAnimation);
}
// Provide a required override for the VisualChildrenCount property.
protected override int VisualChildrenCount
{
get { return _visual == null ? 0 : 1; }
}
// Provide a required override for the GetVisualChild method.
protected override Visual GetVisualChild(int index)
{
return _visual;
}
}
一些有趣的点:
TileHost
可以从其Scale
属性计算其大小。- 使用
DrawingVisual
和DrawingContext
是在FrameworkElement
上打印图像的有效方法,如果不需要交互的话。 - “混合”动画在图块完成渲染时执行。
整合在一起
现在我们知道如何下载和在屏幕上绘制单个图块。将所有内容绑定在一起:
- 一个以
MultiScaleImageSpatialItemsSource
作为ItemsSource
的ItemsControl
将显示图块。 - 该
ItemsControl
的ItemsPanel
是一个ZoomableCanvas
,由于实现了ISpatialItemsSource
,因此它能够虚拟化 UI 和数据。 - 上一节定义的
TileHost
将用作ItemTemplate
,在屏幕上显示图像。
实现这一切的类是 MultiScaleImage
,它是开发者的入口点。该类的默认模板如下所示:
<ControlTemplate TargetType="{x:Type local:MultiScaleImage}">
<ItemsControl x:Name="PART_ItemsControl"
Background="Transparent" ClipToBounds="True">
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
<Setter Property="Canvas.Top" Value="{Binding Top}"/>
<Setter Property="Canvas.Left" Value="{Binding Left}"/>
<Setter Property="Panel.ZIndex" Value="{Binding ZIndex}"/>
</Style>
</ItemsControl.ItemContainerStyle>
<ItemsControl.ItemTemplate>
<DataTemplate>
<local:TileHost Source="{Binding Source}" Scale="{Binding Scale}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ControlTemplate>
您可能会问,ZoomableCanvas
在哪里?由于我们需要从类中访问它,如果我们将其设置为 ItemsControl.ItemsPanel
,我们将无法访问它的命名空间。为了解决这个问题,MultiScaleImage
类注入了 ZoomableCanvas
并保留了对其的引用:
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
_itemsControl = GetTemplateChild("PART_ItemsControl") as ItemsControl;
if (_itemsControl == null) return;
_itemsControl.ApplyTemplate();
var factoryPanel = new FrameworkElementFactory(typeof(ZoomableCanvas));
factoryPanel.AddHandler(LoadedEvent, new RoutedEventHandler(ZoomableCanvasLoaded));
_itemsControl.ItemsPanel = new ItemsPanelTemplate(factoryPanel);
// (...)
}
private void ZoomableCanvasLoaded(object sender, RoutedEventArgs e)
{
// Got the reference to the actual instance of ZoomableCanvas!
_zoomableCanvas = sender as ZoomableCanvas;
// (...)
}
平移和缩放
Silverlight 中的 MultiScaleImage
默认不包含任何交互性。开发人员必须实现平移和缩放支持,以便用户可以操作图像。在此版本中,我在控件中包含了鼠标和多点触控的平移和缩放功能,以便更轻松地直接使用。
为此,我重写了 OnManipulationDelta
方法,并添加了多点触控平移和缩放支持。还重写了 OnManipulationInertiaStarting
以使动画更流畅。
问题在于,仅使用操作事件,控件将不支持鼠标。为了克服这个问题,我使用了 Josh Blake 的 Blake.NUI [^] 库中的 MouseTouchDevice
。该类使鼠标能够充当单点触控设备,从而更容易通过单一入口点创建鼠标和多点触控交互。
鼠标支持的最后一步是鼠标滚轮。通过重写 OnPreviewMouseWheel
,我们可以捕获鼠标滚轮并为缩放级别设置动画。
操作代码中的一个有趣点是使用了缩放事件的**节流(throttling)**。由于用户可能会非常快速地放大和缩小,级别会快速变化,迫使 ZoomableCanvas
为不必要的目的多次重新计算图块,而用户正在从一个级别移动到另一个级别。为了解决这个问题,我们使用 DispatcherTimer
将级别更改限制在预定义的间隔内:
private int _desiredLevel;
private readonly DispatcherTimer _levelChangeThrottle;
public MultiScaleImage()
{
//(...)
_levelChangeThrottle = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(ThrottleIntervalMilliseconds),
IsEnabled = false
};
_levelChangeThrottle.Tick += (s, e) =>
{
_spatialSource.CurrentLevel = _desiredLevel;
_levelChangeThrottle.IsEnabled = false;
};
}
private void ScaleCanvas(/*...*/)
{
// (...)
if (newLevel != level)
{
// If it's zooming in, throttle
if (newLevel > level)
ThrottleChangeLevel(newLevel);
else
_spatialSource.CurrentLevel = newLevel;
}
// (...)
}
private void ThrottleChangeLevel(int newLevel)
{
_desiredLevel = newLevel;
if (_levelChangeThrottle.IsEnabled)
_levelChangeThrottle.Stop();
_levelChangeThrottle.Start();
}
添加 TypeConverter
使开发人员更容易使用此控件的最后一步是使他们能够直接在 XAML 中使用它。如果没有 TypeConverter
,代码将如下所示:
<dz:MultiScaleImage>
<dz:MultiScaleImage.Source>
<dz:DeepZoomImageTileSource
UriSource="http://static.seadragon.com/content/misc/color-flower.dzi" />
</dz:MultiScaleImage.Source>
</dz:MultiScaleImage>
通过使用 TypeConverter
,我们可以直接将字符串转换为其他类型,从而实现如下代码:
<dz:MultiScaleImage Source="http://static.seadragon.com/content/misc/color-flower.dzi" />
秘密是什么?要做到这一点,我们必须首先创建一个派生自 TypeConverter
的类,如下所示:
public class DeepZoomImageTileSourceConverter : TypeConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext context,
Type sourceType)
{
if (sourceType == typeof(string))
return true;
return base.CanConvertFrom(context, sourceType);
}
public override bool CanConvertTo(ITypeDescriptorContext context,
Type destinationType)
{
if (destinationType == typeof(string))
return true;
return base.CanConvertTo(context, destinationType);
}
public override object ConvertFrom(ITypeDescriptorContext context,
CultureInfo culture, object value)
{
var inputString = value as string;
if (inputString != null)
{
try
{
// This is the only important line of code in this file :)
return new DeepZoomImageTileSource(
new Uri(inputString, UriKind.RelativeOrAbsolute)
);
}
catch (Exception ex)
{
throw new Exception(string.Format(
"Cannot convert '{0}' ({1}) - {2}",
value,
value.GetType(),
ex.Message
), ex);
}
}
return base.ConvertFrom(context, culture, value);
}
public override object ConvertTo(ITypeDescriptorContext context,
CultureInfo culture, object value,
Type destinationType)
{
if (destinationType == null)
throw new ArgumentNullException("destinationType");
var tileSource = value as DeepZoomImageTileSource;
if (tileSource != null)
if (CanConvertTo(context, destinationType))
{
var uri = tileSource.UriSource;
return uri.IsAbsoluteUri ? uri.AbsoluteUri : uri.OriginalString;
}
return base.ConvertTo(context, culture, value, destinationType);
}
}
最后,我们必须声明此转换器可用于将字符串转换为 MultiScaleTileSource
类型的类:
[TypeConverter(typeof(DeepZoomImageTileSourceConverter))]
public abstract class MultiScaleTileSource : DependencyObject
{
/*(...)*/
}
就是这样!
奖励:创建自定义图块源
最后,我们将看到实现自定义 Deep Zoom 图块源有多么容易。在此示例中,我们将为 **OpenStreetMap** 创建一个图块源。
**注意**:在生产环境中不建议直接访问 OSM 图块。请勿滥用此服务!
为了实现自定义 MultiScaleTileSource
,我们只需要知道如何根据图块级别、X 和 Y 坐标生成 Uri
。根据 OpenStreetMap wiki,Osmarender 地图的 URL 格式为 http://tah.openstreetmap.org/Tiles/tile/{zoom}/{x}/{y}.png。
另外请注意,OSM 的缩放级别与 Deep Zoom 的缩放级别不同;OSM 的缩放级别 0 对应一个可见的 256x256 图块,这对应于 Deep Zoom 中的级别 log2256 = 8。
因此,我们需要的全部代码是:
public class OpenStreetMapTileSource : MultiScaleTileSource
{
public OpenStreetMapTileSource() : base(0x8000000, 0x8000000, 256, 0) { }
protected override object GetTileLayers(int tileLevel,
int tilePositionX, int tilePositionY)
{
var zoom = tileLevel - 8;
if (zoom >= 0)
return new Uri(string.Format(
"http://tah.openstreetmap.org/Tiles/tile/{0}/{1}/{2}.png",
zoom,
tilePositionX,
tilePositionY));
else
return null;
}
}
在 XAML 文件中使用它的代码如下所示:
<dz:MultiScaleImage>
<dz:MultiScaleImage.Source>
<osm:OpenStreetMapTileSource />
</dz:MultiScaleImage.Source>
</dz:MultiScaleImage>
总结
在本文中,我们利用了许多不同来源的想法,开发了 elusive 的 WPF Deep Zoom 控件。该控件仍处于起步阶段,因此代码也可在 CodePlex [^] 上找到。请帮助我们调整和改进此控件,以实现最佳的 Deep Zoom 体验!
我希望本文能够展示一些关于 WPF 中高性能界面的想法。如果您使用此控件开发了任何内容,请在评论部分发布链接!
未来展望
此应用程序的一些有趣后续步骤可能是
- 添加对 Deep Zoom 集合的支持。
- 提高性能,尤其是在更深的缩放级别(> 22)。
- 使用统一的(可能是原生的)渲染表面,而不是多个
TileHost
。 - 移除图块数量的
int.MaxValue
限制。这可能需要重新实现ZoomableCanvas
以使用不同的虚拟化系统。 - 实现一个更优化的图块下载/缓存和取消下载系统。
- 将交互性与
MultiScaleImage
分离,以与 Silverlight 保持一致。这个新控件将包装MultiScaleImage
,添加交互性、导航等。
您怎么看?
这个项目有用吗?您会在 WPF 中如何实现 Deep Zoom?有什么需要进一步解释的地方吗?
请留下您的评论和建议,如果本文让您满意,请投票支持。谢谢!
其他链接和参考
- Jaime Rodriguez 的 Deep Zoom Primer
- Daniel Gasienica 关于 Deep Zoom 的文章:[1] [2] [3]
- OpenZoom,一个开源的 Flash Deep Zoom 实现
- Kael Rowan 关于 ZoomableCanvas 的文章 [0] [1] [2] [3],以及 MathExtensions
- Josh Blake 的 Blake.NUI 库
历史
- v1.0 (2010/11/22) - 初始发布。