Deep Zoom

My Presentation to the Bing Maps UK User Group

Here’s the video of my presentation to the first Bing Maps UK User Group earlier this month. There’s a short piece missing during the Ambleside walking demo, which is a shame, but otherwise it’s fine.

Sorry about saying ‘umm’ a lot.

(Update: The original host for the video has long-since ceased running, but I had downloaded the original, so now it’s hosted on YouTube.)

Advertisements

Scale other content on top of a Deep Zoom Image

Marthinus asked, in a comment:

“is there ANY way to overwrite this, so that I can make the msi use a multiscalesubimage WITH an extra canvas ontop (which would then move and scale WITH the subimage)?”

Now, I’ve never done overlays with collections and sub-images, and one thing you definitely can’t do is interleave other content with subimages of a deep zoom image (since the MultiScaleImage is a single UI element). But I have done things which have locked an overlay panel to the movement of the MultiScaleImage. Here’s a sample. First, the Xaml.


<UserControl x:Class="DeepZoomCanvas.MainPage"
 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" 
 mc:Ignorable="d" d:DesignWidth="640" d:DesignHeight="480">
 <Grid x:Name="LayoutRoot">
 <MultiScaleImage x:Name="msi" Source="http://www.uslot.com/ClientBin/dzc_output.xml"/>
 <Grid 
 IsHitTestVisible="False"
 x:Name="overlay">
 <Grid.RenderTransform>
 <TransformGroup>
 <ScaleTransform x:Name="overlayScale"/>
 <TranslateTransform x:Name="overlayTranslate"/>
 </TransformGroup>
 </Grid.RenderTransform>
 <Border 
 CornerRadius="8" 
 Background="#44FFFFFF" 
 VerticalAlignment="Center" 
 HorizontalAlignment="Center" 
 Padding="30">
 <StackPanel>
 <TextBlock 
 FontFamily="Arial" 
 FontSize="24" 
 MaxWidth="400" 
 FontWeight="Bold" 
 TextWrapping="Wrap">
 This shows some elements which 
 scale and translate locked to 
 the underlying MultiScaleImage. 
 Any layout items can be used here.
 </TextBlock>
 <Border Background="#55FF0000" Padding="10" HorizontalAlignment="Center" VerticalAlignment="Top">
 <StackPanel>
 <TextBlock 
 FontFamily="Arial"
 FontSize="0.1"
 >This is a tiny line of text which still scales in concert with the deep zoom image.</TextBlock>
 </StackPanel>
 </Border>
 </StackPanel>
 </Border>
 </Grid>
 </Grid>
</UserControl>

And the corresponding C# code:


using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Shapes;

namespace DeepZoomCanvas
{
 public partial class MainPage : UserControl
 {
 public MainPage()
 {
 InitializeComponent();
 msi.MouseLeftButtonDown += new MouseButtonEventHandler(msi_MouseLeftButtonDown);
 msi.MouseMove += new MouseEventHandler(msi_MouseMove);
 msi.MouseLeftButtonUp += new MouseButtonEventHandler(msi_MouseLeftButtonUp);
 msi.MouseWheel += new MouseWheelEventHandler(msi_MouseWheel);

 // Track changes to the multi scale image
 msi.ViewportChanged += new RoutedEventHandler(msi_ViewportChanged);
 }

 /// <summary>
 /// This is the code which locks the overlay to the underlying deep zoom image.
 /// All it really does is set the scale factor and offset of the overlay
 /// based on the current setting of the deep zoom image.
 /// </summary>
 ///
<param name="sender">event sender</param>
 ///
<param name="e">event args</param>
 void msi_ViewportChanged(object sender, RoutedEventArgs e)
 {
 // This event is called during animations of the image.
 // Match the scaling of the canvas with the image
 Point viewportOrigin = msi.ViewportOrigin;
 double viewportWidth = msi.ViewportWidth;

 // The scale factor is just the inverse of the ViewportWidth
 overlayScale.ScaleX = 1 / viewportWidth;
 overlayScale.ScaleY = 1 / viewportWidth;

 // The offset is calculated by finding the location of the origin of the dzi
 // in element coordinates.
 Point newO = LogicalToElement(new Point(), viewportOrigin, viewportWidth);
 overlayTranslate.X = newO.X;
 overlayTranslate.Y = newO.Y;
 }

 private Point LogicalToElement(Point p, Point Origin, double Width)
 {
 return new Point(((p.X - Origin.X) / Width) * msi.ActualWidth,
 ((p.Y - Origin.Y) / Width) * msi.ActualWidth);
 }

 public Point ElementToLogical(Point p, Point Origin, double Width)
 {
 return new Point(Origin.X + (p.X * Width) / msi.ActualWidth,
 Origin.Y + (p.Y * Width) / msi.ActualWidth);
 }

#region Mouse handling
 void msi_MouseWheel(object sender, MouseWheelEventArgs e)
 {
 if (e.Delta < 0)
 {
 Point logicalPoint = msi.ElementToLogicalPoint(lastMousePos);
 msi.ZoomAboutLogicalPoint(0.8, logicalPoint.X, logicalPoint.Y);

 }
 else
 {
 Point logicalPoint = msi.ElementToLogicalPoint(lastMousePos);
 msi.ZoomAboutLogicalPoint(1.2, logicalPoint.X, logicalPoint.Y);
 }
 }

 void msi_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
 {
 if (IsMouseDown)
 {
 msi.ReleaseMouseCapture();
 if (IsDrag == false)
 {
 if ((Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control)
 {
 Point logicalPoint = msi.ElementToLogicalPoint(lastMousePos);
 msi.ZoomAboutLogicalPoint(0.8, logicalPoint.X, logicalPoint.Y);
 }
 else
 {
 Point logicalPoint = msi.ElementToLogicalPoint(lastMousePos);
 msi.ZoomAboutLogicalPoint(1.2, logicalPoint.X, logicalPoint.Y);
 }
 }
 IsMouseDown = false;
 IsDrag = false;
 }
 }

 void msi_MouseMove(object sender, MouseEventArgs e)
 {
 lastMousePos = e.GetPosition(msi);
 if (IsMouseDown)
 {
 IsDrag = true;
 }
 if (IsDrag)
 {
 Point newPoint = lastMouseViewPort;
 newPoint.X += (lastMouseDownPos.X - lastMousePos.X) / msi.ActualWidth * msi.ViewportWidth;
 newPoint.Y += (lastMouseDownPos.Y - lastMousePos.Y) / msi.ActualWidth * msi.ViewportWidth;
 msi.ViewportOrigin = newPoint;
 }
 }

 bool IsMouseDown = false;
 bool IsDrag = false;
 private Point lastMouseDownPos = new Point();
 private Point lastMouseViewPort = new Point();
 private Point lastMousePos = new Point();

 void msi_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
 {
 IsMouseDown = true;
 msi.CaptureMouse();
 lastMouseDownPos = e.GetPosition(msi);
 lastMouseViewPort = msi.ViewportOrigin;
 }

 #endregion    
 }
}

All the work is done in the ViewportChanged event handler (which fires every time the viewport changes, even during animations). I’ve set a scale transform and a translate transform on the grid (which could be a canvas if you want) and we adjust the scale and translate values to match the underlying image.

All the rest of the code is just bog-standard deep zoom image mouse handling.

Try zooming into the red square to see a very small line of text.

Hope this helps.

Wrangling very large Deep Zoom images

My current project features a large Deep Zoom image. Very large. About 123 Gigapixels, in fact. Here’s the raw numbers:

  • 20 tile levels (0-19)
  • about 20GB of jpeg images
  • about 2.5 million tiles
  • almost 1.9 million tiles just in the highest level of zoom

First of all, what’s impressive is that Silverlight doesn’t even break a sweat with this image. Deep Zoom is designed such that it almost doesn’t matter how big the raw image is, it only matters how many pixels you’ve got on your screen, because it will always show you the resolution and section of the picture that you want. The main constraint is how fast you can serve (and download) the tiles.

But such a large image and tile set brings some interesting problems, and I wanted to post a bit about some of the issues we’ve found, and how we’ve addressed them.

Lots of files in one place are bad

Although not cripplingly so…

As mentioned earlier, our top level of tiles (the native resolution level) contained almost 1.9 million tiles. This is how Deep Zoom composer arranges its output and how Silverlight’s MultiScaleImage expects to find them. And it still works. Although one thing we learned early on is that FAT32 can’t cope with that number of files. We use removable hard drives for convenience, and I would always forget to reformat them to NTFS, then spend hours trying to build our image, only to fail because it ran out of directory entries. Grr.

But Fat32 aside, serving the images from NTFS on these tiny hard drives was very smooth. It didn’t seem to worry that there were so many files. The problem comes when copying the files, particularly to a network server. The copy would always start out fairly nippy, but when it got to the larger directories, it would just grind to a halt, probably because it was doing a directory enumeration each time over the network. It was definitely slowing down over time.

The solution I decided to use was to rearrange the files. I decided to take the X index of the filename and use that to generate a subdirectory in which to store the file. This would mean that, for example, if I had a file 142_1232.jpg in level 19, it would move from 19\142_1232.jpg to 19\1\4\2\142_1232.jpg. This method means that the maximum number of files in any single directory would now depend on the height of the image, rather than the area – in our specific case limiting us to 1718 files maximum.

Now, this is all very well, but the MultiScaleImage expects the files in their original places, so how do we fix that problem. Two ways would work. The first would be to use URL rewriting on the server side (mod_rewrite on Apache, for example) which would work OK, but we’d need two different solutions depending whether it’s Apache or WIndows serving the files. The second way is to write a custom tile source for MultiScaleImage.

Advantages of a Custom TileSource

The main advantage is that we don’t care where the images are hosted – they remain just files in a directory structure, and can be served fast by the web server. Also, avoiding mod_rewrite etc. might be useful as that’s more work for the servers to do that could be better performed by the client. It also means that the same solution works whether we’re serving locally (for testing) or serving from a dev server (which would be running Apache, probably).

Problems with a Custom TileSource

Although it’s easy enough to inherit from the base MultiScaleTileSource class, they’ve not made it very easy to do exactly what you need to do. I wanted to be able to create a class which had a constructor which took a Uri, and which read from an Xml file to get information about size etc. But this seems to be impossible. MultiScaleTileSource expects you to know already the dimensions of your image when the constructor is called, and doesn’t allow any way to initialise these values after the constructor is called, due to the protection level of other members. And since Silverlight doesn’t have a synchronous way to read from a file, you can’t open the file in the constructor. Annoying.

In the end, I cheated, because I know how big my image is already. I’ve already got code which takes the Uri of the image from the host HTML page, so I adjusted that to take the path to the new files, along with the width and height, as parameters which I can then pass to my new constructor, bypassing the need to read from a file. It’s not ideal, and I hope that this process is opened up a little in future.

Once I’d arrived at this way of initialising my class, writing the override method to return the tile paths was a little easier, although again I had to hard-code some specific information about my particular image – My image has a virtual square shape, but is actually rectangular, so there are lots of virtual tiles which don’t actually exist. DeepZoomImageTileSource handles this with the information in the xml file (it’s all part of the ‘sparse’ nature of deep zoom images) but I just hard-coded the limits of my tileset.

This solution works well for us. I haven’t tested it to see if it makes a difference with serving the images, but it definitely drastically reduced the time it takes to deploy our images to a server – from something that had already taken days and was slowing down, to something that completed within four hours. So that was a win for us.

I’m not sure how useful this code would be, but I’m including it anyway, for illustration.

public class HashedDeepZoomTileSource : MultiScaleTileSource
{
    private string RootPath;

    public HashedDeepZoomTileSource(string root, int imageWidth, int imageHeight) : base(imageWidth,imageHeight,256,256,1)
    {
        RootPath = root;

    }

    ///

    /// Constructs a tilesource given a string describing the root of the image
    /// And the image size.
    ///

    ///string containing the root directory (relative to XAP file) and the
    /// width and height packed in the following form:
    /// root|width|height
    ///
    /// so an image with a root directory GeneratedImages/uk3_files and width of 232000
    /// and a height of 445000 would have a string:
    ///
    /// GeneratedImages/uk3_files|232000|445000
    ///
    /// If the path is a regular path, then we simply construct a normal DeepZoomImageTileSource.
    ///     /// Either A HashedDeepZoomTileSource object or a normal DeepZoomImageTileSource
    public static MultiScaleTileSource UnpackPath(string packedRoot)
    {
        string[] parts = packedRoot.Split(‘|’);
        int width;
        int height;
        if (parts.Length != 3 || !int.TryParse(parts[1], out width) || !int.TryParse(parts[2], out height))
        {
            return new DeepZoomImageTileSource(new Uri(packedRoot, UriKind.Relative));
        }
        return new HashedDeepZoomTileSource(parts[0], width, height);
    }

    ///

    /// This is a hack, It (and maxheights) describes the maximum tile ID available
    /// at all the tile heights in the image.
    ///

    int[] maxwidths =
    {
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        1,
        2,
        4,
        8,
        17,
        34,
        68,
        136,
        273,
        546,
        1093

    };

    int[] maxheights =
    {
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        1,
        3,
        6,
        13,
        26,
        53,
        107,
        214,
        429,
        859,
        1718
    };

    ///

    /// Overrides the GetTileLayers method to provide a URL for the given tile source.
    /// We have to create a fully qualified domain path – it doesn’t like relative
    /// paths.
    ///

    ///Which level of tile resolution (0-19 e.g.)     ///X position of required tile     ///Y Position of required tile     ///List to populate with Uris pointing at the tiles they want     protected override void GetTileLayers(int tileLevel, int tilePositionX, int tilePositionY, System.Collections.Generic.IList tileImageLayerSources)
    {
        if (tileLevel >= 0)
        {
            // MASSIVE KLUDGE
            // Since our map is ‘sparse’ in that its logical size if 480000 square
            // but we only have tiles for a width of 280000 so we have to not return
            // non-existent URLs.
            // We look up the maximum X position value from the maxwidths

            if (tilePositionX <= maxwidths[tileLevel] && tilePositionY <= maxheights[tileLevel])             {                 StringBuilder path = new StringBuilder(RootPath);                 if (RootPath.EndsWith("/") == false)                 {                     path.Append("/");                 }                 path.AppendFormat("{0}/", tileLevel);                 foreach (char digit in tilePositionX.ToString().ToCharArray())                 {                     path.AppendFormat("{0}/", digit);                 }                 path.AppendFormat("{0}_{1}.jpg", tilePositionX, tilePositionY);                 string s = App.Current.Host.Source.ToString();                 s = s.Substring(0, s.LastIndexOf('/') + 1);                 tileImageLayerSources.Add(new Uri(s + path.ToString(), UriKind.Absolute));             }         }     } } [/sourcecode]

Programmatically Create Deep Zoom Collections

The Expression team have released a new version of Deep Zoom Composer, and at last they’ve supplied a .NET assembly for creating Deep Zoom images. This has always been possible, using the SparseImageTool.exe that was supplied as part of Composer, but that was never a very good solution, particularly for large collections of images.

We have a potential use case where we’d want to create millions of deep zoom images, and expose these as collections, but without necessarily knowing which images were in a collection ahead of time. Or we might want to add new images to a large collection. Until now, that has always been impossible just using SparseImageTool, but using the new DLL it’s easy. Here’s some sample code which I just threw together to see how the API worked. You need two classes. The ImageCreator class creates individual Deep Zoom images, which was always easy with SparseImageTool. But the CollectionCreator can then take these images, and build a composition of all those images.


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
using Microsoft.DeepZoomTools;
using Path = System.IO.Path;

namespace DZCtest
{
    /// <summary>
    /// Interaction logic for Window1.xaml
    /// </summary>
    public partial class Window1 : Window
    {
        public Window1()
        {
            InitializeComponent();
        }

        private void Create_Click(object sender, RoutedEventArgs e)
        {
            ImageCreator creator = new ImageCreator();
            creator.TileFormat = ImageFormat.Jpg;
            creator.TileOverlap = 1;
            creator.TileSize = 256;

            List<string> files = new List<string>()
            {
                @"D:\Users\Pictures\DadsArmy\hi000350807.jpg",
                @"D:\Users\Pictures\DadsArmy\hi000350820.jpg",
                @"D:\Users\Pictures\DadsArmy\hi000360009.jpg"
            };
            string root = @"D:\Users\James\Visual Studio 2008\Projects\DeepZoomProjects\DeepZoomTests_Web\ClientBin\GeneratedImages\";
            List<string> dzi = new List<string>();
            foreach (var name in files)
            {
                string output = Path.Combine(root, Path.GetFileNameWithoutExtension(name) + ".dzi");
                dzi.Add(output);
                creator.Create(name, output);
            }

            CollectionCreator ccreator = new CollectionCreator();
            ccreator.TileFormat = ImageFormat.Jpg;
            ccreator.TileOverlap = 1;
            ccreator.TileSize = 256;
            ccreator.Create(dzi, Path.Combine(root, "da.dzc"));
        }
    }
}

You’ll have to add a Reference to DeepZoomTools.dll which is installed as part of Deep Zoom Composer. All this code does is take three hardcoded image paths, generates individual deep zoom images for them, then creates an overall collection of all three.

One thing there doesn’t seem to be is any way to lay out the collection other than having all the images on top of one another, but since any code which makes use of a collection would probably handle its own layout in code, this probably isn’t much of a drawback. And there might well another way to do that – I’ve literally only just thrown this code together, so I haven’t delved very far into what’s exposed.

It’ll be interesting to see what the performance is like. In a perfect world, I’d be able to do a search across millions of images and dynamically generate a collection of a few hundred images in response to a user request, but that might be asking a bit too much.

UPDATE: 9 minutes to process 305 images. 20 seconds to build the collection from those images. 20 seconds is probably a bit more than you’d want for a completely dynamic search, for example, but it’s probably absolutely fine if you’re uploading images to a more static collection like a picture album. 2 seconds to process a typical digital camera image is really rather good. Next test will be some of the really large images that DZC choked on last time I tried it.

Nice post about Deep Zoom positioning

From the Silverlight SDK:

http://blogs.msdn.com/silverlight_sdk/archive/2008/11/18/using-viewportorigin-and-viewportwidth-in-deep-zoom.aspx

Although we’ve all got this worked out already, there’s some good illustrations, which always help to visualise how it works.

It’s a pity that they don’t go further into collection handling – that adds a further layer to the way the geometry is handled, as each subimage has its own ViewportWidth and ViewportOrigin and the way these interact with the parent image isn’t always obvious. I certainly had to work it all out by trial and error.

A simple Library to Build a Deep Zoom Image

My current project requires a lot of work with Deep Zoom images. We recently received some very high-res photos, around 500M tiff files, some of which Deep Zoom Composer was unable to process. My first thought was to split them into smaller pieces and compose them together, but this led to visible join lines when processed, possibly due to rounding issues.

I’d already written some code to generate parts of a much larger deep zoom image, which was focused on building or rebuilding a small portion of much larger composition (over 2.5 million tiles in total) but it wasn’t quite ready to build a whole DZI.

So I took the bits of the code I needed and put them in a class by themselves. It’s very simple, and only designed to take a single source image and create a deep zoom tileset which can then be used by Silverlight.

It’s fairly unsophisticated in how it works. It loads in the source image (so you need enough RAM to hold that image at least) then renders out the top level tiles. Then it dumps the source image and renders the next levels down, rendering each one using the tiles of the level above. This seems to give a good result and was a technique I used previously when I was stitching together many source images and finding that DZC would leave ugly join lines at certain zoom levels.

The code feels a bit hacky, since most of the calculations were rough guesses or trial and error, but it definitely works for a single image, and it might be useful to someone, maybe as a server-side component to build deep zoom images on the fly. It’s biggest drawback is that it takes a long time, but if you’re using it in a WPF app you can always run it using a BackgroundWorker.

Note that this is WPF code, not Silverlight.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.IO;
using System.Windows.Media.Imaging;
using Path = System.IO.Path;
using System.Windows;
using System.Windows.Media;
using System.Xml.Linq;

namespace DeepZoomBuilder
{
	public enum ImageType
	{
		Png,
		Jpeg
	}

	public class DeepZoomCreator
	{
		/// <summary>
		/// Default public constructor
		/// </summary>
		public DeepZoomCreator() { }

		/// <summary>
		/// Create a deep zoom image from a single source image
		/// </summary>
		///
<param name="sourceImage">Source image path</param>
		///
<param name="destinationImage">Destination path (must be .dzi or .xml)</param>
		public void CreateSingleComposition(string sourceImage, string destinationImage, ImageType type)
		{
			imageType = type;
			string source = sourceImage;
			string destDirectory = Path.GetDirectoryName(destinationImage);
			string leafname = Path.GetFileNameWithoutExtension(destinationImage);
			string root = Path.Combine(destDirectory, leafname); ;
			string filesdir = root + "_files";

			Directory.CreateDirectory(filesdir);
			BitmapImage img = new BitmapImage(new Uri(source));
			double dWidth = img.PixelWidth;
			double dHeight = img.PixelHeight;
			double AspectRatio = dWidth / dHeight;

			// The Maximum level for the pyramid of images is
			// Log2(maxdimension)

			double maxdimension = Math.Max(dWidth, dHeight);
			double logvalue = Math.Log(maxdimension, 2);
			int MaxLevel = (int)Math.Ceiling(logvalue);
			string topleveldir = Path.Combine(filesdir, MaxLevel.ToString());

			// Create the directory for the top level tiles
			Directory.CreateDirectory(topleveldir);

			// Calculate how many tiles across and down
			int maxcols = img.PixelWidth / 256;
			int maxrows = img.PixelHeight / 256;

			// Get the bounding rectangle of the source image, for clipping
			Rect MainRect = new Rect(0, 0, img.PixelWidth, img.PixelHeight);
			for (int j = 0; j <= maxrows; j++)
			{
				for (int i = 0; i <= maxcols; i++)
				{
					// Calculate the bounds of the tile
					// including a 1 pixel overlap each side
					Rect smallrect = new Rect((double)(i * 256) - 1, (double)(j * 256) - 1, 258.0, 258.0);

					// Adjust for the rectangles at the edges by intersecting
					smallrect.Intersect(MainRect);

					// We want a RenderTargetBitmap to render this tile into
					// Create one with the dimensions of this tile
					RenderTargetBitmap outbmp = new RenderTargetBitmap((int)smallrect.Width, (int)smallrect.Height, 96, 96, PixelFormats.Pbgra32);
					DrawingVisual visual = new DrawingVisual();
					DrawingContext context = visual.RenderOpen();

					// Set the offset of the source image into the destination bitmap
					// and render it
					Rect rect = new Rect(-smallrect.Left, -smallrect.Top, img.PixelWidth, img.PixelHeight);
					context.DrawImage(img, rect);
					context.Close();
					outbmp.Render(visual);

					// Save the bitmap tile
					string destination = Path.Combine(topleveldir, string.Format("{0}_{1}", i, j));
					EncodeBitmap(outbmp, destination);

					// null out everything we've used so the Garbage Collector
					// knows they're free. This could easily be voodoo since they'll go
					// out of scope, but it can't hurt.
					outbmp = null;
					context = null;
					visual = null;
				}
				GC.Collect();
				GC.WaitForPendingFinalizers();
			}

			// clear the source image since we don't need it anymore
			img = null;
			GC.Collect();
			GC.WaitForPendingFinalizers();

			// Now render the lower levels by rendering the tiles from the level
			// above to the next level down
			for (int level = MaxLevel - 1; level >= 0; level--)
			{
				RenderSubtiles(filesdir, dWidth, dHeight, MaxLevel, level);
			}

			// Now generate the .dzi file

			string format = "png";
			if (imageType == ImageType.Jpeg)
			{
				format = "jpg";
			}

			XElement dzi = new XElement("Image",
				new XAttribute("TileSize", 256),
				new XAttribute("Overlap", 1),
				new XAttribute("Format", format), // xmlns="http://schemas.microsoft.com/deepzoom/2008">
				new XElement("Size",
					new XAttribute("Width", dWidth),
					new XAttribute("Height", dHeight)),
				new XElement("DisplayRects",
					new XElement("DisplayRect",
						new XAttribute("MinLevel", 1),
						new XAttribute("MaxLevel", MaxLevel),
						new XElement("Rect",
							new XAttribute("X", 0),
							new XAttribute("Y", 0),
							new XAttribute("Width", dWidth),
							new XAttribute("Height", dHeight)))));
			dzi.Save(destinationImage);

		}

		/// <summary>
		/// Save the output bitmap as either Png or Jpeg
		/// </summary>
		///
<param name="outbmp">Bitmap to save</param>
		///
<param name="destination">Path to save to, without the file extension</param>
		private void EncodeBitmap(RenderTargetBitmap outbmp, string destination)
		{
			if (imageType == ImageType.Png)
			{
				PngBitmapEncoder encoder = new PngBitmapEncoder();
				encoder.Frames.Add(BitmapFrame.Create(outbmp));
				FileStream fs = new FileStream(destination + ".png", FileMode.Create);
				encoder.Save(fs);
				fs.Close();
			}
			else
			{
				JpegBitmapEncoder encoder = new JpegBitmapEncoder();
				encoder.QualityLevel = 95;
				encoder.Frames.Add(BitmapFrame.Create(outbmp));
				FileStream fs = new FileStream(destination + ".jpg", FileMode.Create);
				encoder.Save(fs);
				fs.Close();
			}
		}

		/// <summary>
		/// Specifies the output filetype
		/// </summary>
		ImageType imageType = ImageType.Jpeg;

		/// <summary>
		/// Render the subtiles given a fully rendered top-level
		/// </summary>
		///
<param name="subfiles">Path to the xxx_files directory</param>
		///
<param name="imageWidth">Width of the source image</param>
		///
<param name="imageHeight">Height of the source image</param>
		///
<param name="maxlevel">Top level of the tileset</param>
		///
<param name="desiredlevel">Level we want to render. Note it requires
		/// that the level above this has already been rendered.</param>
		private void RenderSubtiles(string subfiles, double imageWidth, double imageHeight, int maxlevel, int desiredlevel)
		{
			string formatextension = ".png";
			if (imageType == ImageType.Jpeg)
			{
				formatextension = ".jpg";
			}
			int uponelevel = desiredlevel + 1;
			double desiredfactor = Math.Pow(2, maxlevel - desiredlevel);
			double higherfactor = Math.Pow(2, maxlevel - (desiredlevel + 1));
			string renderlevel = Path.Combine(subfiles, desiredlevel.ToString());
			Directory.CreateDirectory(renderlevel);
			string upperlevel = Path.Combine(subfiles, (desiredlevel + 1).ToString());

			// Calculate the tiles we want to translate down
			Rect MainBounds = new Rect(0, 0, imageWidth, imageHeight);
			Rect OriginalRect = new Rect(0, 0, imageWidth, imageHeight);

			// Scale down this rectangle to the scale factor of the level we want
			MainBounds.X = Math.Ceiling(MainBounds.X / desiredfactor);
			MainBounds.Y = Math.Ceiling(MainBounds.Y / desiredfactor);
			MainBounds.Width = Math.Ceiling(MainBounds.Width / desiredfactor);
			MainBounds.Height = Math.Ceiling(MainBounds.Height / desiredfactor);

			int lowx = (int)Math.Floor(MainBounds.X / 256);
			int lowy = (int)Math.Floor(MainBounds.Y / 256);
			int highx = (int)Math.Floor(MainBounds.Right / 256);
			int highy = (int)Math.Floor(MainBounds.Bottom / 256);

			for (int x = lowx; x <= highx; x++)
			{
				for (int y = lowy; y <= highy; y++)
				{
					Rect smallrect = new Rect((double)(x * 256) - 1, (double)(y * 256) - 1, 258.0, 258.0);
					smallrect.Intersect(MainBounds);
					RenderTargetBitmap outbmp = new RenderTargetBitmap((int)smallrect.Width, (int)smallrect.Height, 96, 96, PixelFormats.Pbgra32);
					DrawingVisual visual = new DrawingVisual();
					DrawingContext context = visual.RenderOpen();

					// Calculate the bounds of this tile

					Rect rect = smallrect;
					// This is the rect of this tile. Now render any appropriate tiles onto it
					// The upper level tiles are twice as big, so they have to be shrunk down

					Rect scaledRect = new Rect(rect.X * 2, rect.Y * 2, rect.Width * 2, rect.Height * 2);
					for (int tx = lowx * 2; tx <= highx * 2 + 1; tx++)
					{
						for (int ty = lowy * 2; ty <= highy * 2 + 1; ty++)
						{
							// See if this tile overlaps
							Rect subrect = GetTileRectangle(tx, ty);
							if (scaledRect.IntersectsWith(subrect))
							{
								subrect.X -= scaledRect.X;
								subrect.Y -= scaledRect.Y;
								RenderTile(context, Path.Combine(upperlevel, tx.ToString() + "_" + ty.ToString() + formatextension), subrect);
							}
						}
					}
					context.Close();
					outbmp.Render(visual);

					// Render the completed tile and clear all resources used
					string destination = Path.Combine(renderlevel, string.Format(@"{0}_{1}", x, y));
					EncodeBitmap(outbmp, destination);
					outbmp = null;
					visual = null;
					context = null;
				}
				GC.Collect();
				GC.WaitForPendingFinalizers();
			}

		}

		/// <summary>
		/// Get the bounds of the given tile rectangle
		/// </summary>
		///
<param name="x">x index of the tile</param>
		///
<param name="y">y index of the tile</param>
		/// <returns>Bounding rectangle for the tile at the given indices</returns>
		private static Rect GetTileRectangle(int x, int y)
		{
			Rect rect = new Rect(256 * x - 1, 256 * y - 1, 258, 258);
			if (x == 0)
			{
				rect.X = 0;
				rect.Width = rect.Width - 1;
			}
			if (y == 0)
			{
				rect.Y = 0;
				rect.Width = rect.Width - 1;
			}

			return rect;
		}

		/// <summary>
		/// Render the given tile rectangle, shrunk down by half to fit the next
		/// lower level
		/// </summary>
		///
<param name="context">DrawingContext for the DrawingVisual to render into</param>
		///
<param name="path">path to the tile we're rendering</param>
		///
<param name="rect">Rectangle to render this tile.</param>
		private void RenderTile(DrawingContext context, string path, Rect rect)
		{
			if (File.Exists(path))
			{
				BitmapImage img = new BitmapImage(new Uri(path));
				rect = new Rect(rect.X / 2.0, rect.Y / 2.0, ((double)img.PixelWidth) / 2.0, ((double)img.PixelHeight) / 2.0);
				context.DrawImage(img, rect);
			}
		}

	}
}

If you do use this for anything, please let me know as I’d love to see it used elsewhere.

Update: This code is now a little redundant, now that the Deep Zoom Composer team have released a DLL to do the same thing.