Silverlight – Dynamic DeepZoom with MultiScaleImage and MultiScaleTileSource

One of the things that I’ve been meaning to experiment with around the MultiScaleImage control in Silverlight 2 Beta 2 was the ability of the control to request its image tiles from an alternate source.

That is, in Silverlight 2 Beta 2 you can create your own type to provide tiled images;

  public class MyTileSource : MultiScaleTileSource
  {
    public MyTileSource(int imageWidth, int imageHeight, int tileSize) :
      base(imageWidth, imageHeight, tileSize, tileSize, 0)
    {
      this.tileSize = tileSize;
      this.imageWidth = imageWidth;
      this.imageHeight = imageHeight;
    }

and then you can set this as the Source of a MultiScaleImage control as in;

      msi.Source = new MyTileSource(
        int.Parse(txtImageWidth.Text),
        int.Parse(txtImageHeight.Text),
        int.Parse(txtTileSize.Text));

and then the MultiScaleImage control will call your code when it wants to load up tiles.

Now…in Silverlight 2 Beta 2 there’s not so much that you can do around drawing bitmapped images. Joe Stegman has a sample here that does do dynamic image generation but he’s using his own custom classes to do that rather than calling into framework classes.

I figured that in order to do my dynamic image generation what I would do is to “remote” the request for a tile across to some server side code which would then do that generation for me. Again, not probably the best idea to use System.Drawing server-side but it worked for what I wanted to play with.

Also, that overridden method above is kind of expecting a synchronous response. That is – I need to add something to that tileImageLayerSources list that I’ve been passed and I need to add it before the method returns. I found it easiest to just add a URI and remote the call to some server-side handler as in;

  public class MyTileSource : MultiScaleTileSource
  {
    public MyTileSource(int imageWidth, int imageHeight, int tileSize) :
      base(imageWidth, imageHeight, tileSize, tileSize, 0)
    {
      this.tileSize = tileSize;
      this.imageWidth = imageWidth;
      this.imageHeight = imageHeight;
    }
    protected override void GetTileLayers(int tileLevel, int tilePositionX, int tilePositionY,
      IList<object> tileImageLayerSources)
    {
      string source =
        string.Format(
          "http://localhost:61843/SilverlightApplication2Web/ClientBin/handler.ashx?" +
          "tileLevel={0}&tilePositionX={1}&tilePositionY={2}&tileSize={3}" +
          "&imageWidth={4}&imageHeight={5}",
          tileLevel, tilePositionX, tilePositionY, tileSize,
          imageWidth, imageHeight);

      Uri uri = new Uri(source, UriKind.Absolute);

      tileImageLayerSources.Add(uri);
    }
    int tileSize;
    int imageWidth;
    int imageHeight;
  }

And so now I can just have a server-side handler that does something in order to generate images. Naturally, I’ve just pushed the problem to the server-side ๐Ÿ™‚ but at least there I have easier mechanisms for dynamically generating bitmaps. So, my ASHX code might look like;

using System;
using System.Web;

public class Handler : IHttpHandler {

    private System.Drawing.Bitmap DrawTile(int tileLevel, int tilePosX, int tilePosY,
        int tileSize, int imageWidth, int imageHeight)
    {
        return (null);
    }
        
    public void ProcessRequest (HttpContext context) {
        context.Response.ContentType = "img/png";

        if (context.Request.QueryString.HasKeys())
        {
            // Being lazy...
            int tileLevel = int.Parse(context.Request.QueryString.Get("tileLevel"));
            int tilePosX = int.Parse(context.Request.QueryString.Get("tilePositionX"));
            int tilePosY = int.Parse(context.Request.QueryString.Get("tilePositionY"));
            int tileSize = int.Parse(context.Request.QueryString.Get("tileSize"));
            int imageWidth = int.Parse(context.Request.QueryString.Get("imageWidth"));
            int imageHeight = int.Parse(context.Request.QueryString.Get("imageHeight"));

            System.Drawing.Bitmap bitmap = DrawTile(tileLevel, tilePosX, tilePosY, tileSize,
                imageWidth, imageHeight);

            if (bitmap != null)
            {
                System.IO.MemoryStream ms = new System.IO.MemoryStream();
                bitmap.Save(ms, System.Drawing.Imaging.ImageFormat.Png);
                ms.WriteTo(context.Response.OutputStream);
            }
        }
    }
 
    public bool IsReusable {
        get {
            return true;
        }
    }

}

and I’ve effectively remoted the problem of producing the tile to the server.

Now…how to produce tiles? There’s a missing piece. When we produced our derived class MyTileSource we constructed the base class MultiScaleTileSource with a bunch of numbers that I didn’t mention.

The base class constructor looks like this;

public MultiScaleTileSource(int imageWidth, int imageHeight, int tileWidth, int tileHeight, int tileOverlap);

And this is as we’re telling the framework that we have an image of dimensions X by Y and that we can provide tiles for that image of a particular width and height and whether we have overlap on our tiles or not.

This all factors into how many zoom levels the control will use for our image.

Then, the framework calls us requesting image tile X, Y at a particular tileLevel. This parameter foxed me for a while and I had to ask for an explanation of it somewhere as I couldn’t find one.

Here’s how these tile levels were explained to me – the tile level is basically a factor of 2. That is;

tileLevel = 0, image size = 1×1 pixels

tileLevel = 1, image size = 2×2 pixels

so for a 1024×1024 image you’re never presumably going to need to go over level 10 ( 2 ^ 10 == 1024 ) in terms of zooming to the maximum level.

More generally, if you want to work out the maximum tile level for an image then, given Width by Height, you can say ( I think );

Max( Ceiling ( Log2 ( Width ) ), Ceiling ( Log2 ( Height ) ) )

to work out what your maximum tile level is going to be because if your image is naturally ( Width x Height ) then at the next tile level down it’s going to be ( Width/2, Height/2 ) and so on until we get to a width and height of ( 1, 1 ).

Armed with that, I set about trying to fill in my DrawTile function above with some code which “simply” creates a tile which has a rectangle drawn around it so that I can see the tile itself and also that represents a larger image which has the current tile level draw into it in the centre.

That is…

image image

So you can see that the current tileLevel is set to 7 and 9 respectively, the tiles are indicated by the dotted border. Here’s what I ended up writing for it;

    private System.Drawing.Bitmap DrawTile(int tileLevel, int tilePosX, int tilePosY,
        int tileSize, int imageWidth, int imageHeight)
    {
        System.Drawing.Bitmap b = new System.Drawing.Bitmap(tileSize, tileSize);

        int maxTileLevel = (int)Math.Max(
            Math.Ceiling(Math.Log(imageWidth, 2)), Math.Ceiling(Math.Log(imageHeight, 2)));

        int currentImageDivisor = maxTileLevel - tileLevel;
        
        int naturalWidth =
            Math.Max((int)(imageWidth / Math.Pow(2, currentImageDivisor)), tileSize);

        int naturalHeight =
            Math.Max((int)(imageHeight / Math.Pow(2, currentImageDivisor)), tileSize);                
        
        using (System.Drawing.Graphics g = System.Drawing.Graphics.FromImage(b))
        {
            string text = tileLevel.ToString();

            g.FillRectangle(System.Drawing.Brushes.PowderBlue, 0, 0, tileSize, tileSize);

            using (System.Drawing.Pen p = new System.Drawing.Pen(System.Drawing.Brushes.White, 1))
            {
                p.DashStyle = System.Drawing.Drawing2D.DashStyle.Dot;
                g.DrawRectangle(p, 5, 5, (tileSize - 10), (tileSize - 10));
            }

            using (System.Drawing.Font f = new System.Drawing.Font("Verdana", 8))
            {
                System.Drawing.SizeF textSize = g.MeasureString(text, f);

                int offsetX = (0 - tilePosX) * (tileSize);
                int offsetY = (0 - tilePosY) * (tileSize);
                g.TranslateTransform(offsetX, offsetY);

                float scaleX = naturalWidth / textSize.Width;
                float scaleY = naturalHeight / textSize.Height;

                g.ScaleTransform(scaleX, scaleY);

                g.DrawString(text, f, System.Drawing.Brushes.White, 0, 0);
            }
        }
        return (b);
    }

Drawing the tile at the right tile size and putting a rectangle into it seems pretty easy. The slightly more tricky thing I’m trying to do here is to figure out what the natural size of the image would be at the current tileLevel in order that I can centre my tileLevel text in the middle of that larger image when you stitch the tiles back together. Hence all the naturalHeight and scaleX, scaleY, offsetX, offsetY stuff that’s going on. There’s probably a better way of doing it ๐Ÿ™‚ but it was the first way that came to mind.

I’ve put the solution file here to share if you’re interested in taking it further ( or maybe just fixing what I’ve done ๐Ÿ™‚ ). Enjoy.