With our tile services its easy to add layers of imagery to applications that support WMTS and X/Y/Z tiles. In these applications, a ‘template’ URL is used with placeholders for the tile coordinates that are filled in by the application at runtime. A typical template url would look something like this one for Vexcel’s blue sky ultra layer:
https://api.gic.org/images/GetOrthoImageTile/bluesky-ultra/{z}/{x}/{y}?token=YOURTOKEN
and here is the template for adding a layer of Open Street Map tiles.
http://a.tile.openstreetmap.org/{z}/{x}/{y}.png
In both cases, the host application will fill in the X, Y, and Z values dynamically to request the tiles needed to fill the map area. This is the most common use of tile services, but they can also be accessed programmatically to create maps by stitching a series of tiles together. Many map providers such as Google and Bing provide static map api’s that do this for you. In this article we’ll build a static map service that does exactly that.
Lets start by looking at the completed service, and trying it out. In its most basic form it will return a standard map image in PNG format but you can optionally pass in a polygon with the WKT parameter and have that polygon drawn on the map as an overlay or have the map cropped to that polygon. Lets look at examples of all three, then we’ll dive into the code to implement it.
Here is a simple request to the service along with the resulting stitched and cropped image. The required parameters are the latitude, longitude and zoom level of the desired map. layer is also a required parameter and should be one of the Vexcel imagery layers. width and height are optional and represent the size in pixels of the resulting bitmap. If not passed, these parameters default to 400 pixels. the authtoken parameter is also required and carries the standard authentication token generated by the login method of the Vexcel API.
https://geointeldemo.azurewebsites.net/api/staticMap?layer=bluesky-ultra&zoom=21&latitude=34.0837&longitude=-118.33447&width=800&height=600&authtoken=YOURTOKEN

Next we’ll pass in a polygon to be drawn over the stitched map image. The rest of the parameters remain the same. The polygon itself is passed in the wkt parameter, and surprise, should be in wkt format. The wktaction parameter specifies whether the polygon should be drawn or used for cropping. in this first case we set its value to ‘draw’.
https://geointeldemo.azurewebsites.net/api/staticMap?layer=bluesky-ultra&zoom=21&latitude=34.0837&longitude=-118.33447&width=800&height=600&wktaction=draw&wkt=polygon((-118.334267647877%2034.0837817868285,-118.334687397779%2034.0837807345763,-118.334686980325%2034.0837094282591,-118.334686287342%2034.0835857756008,-118.334266542153%2034.0835877621179,-118.334267647877%2034.0837817868285))&authtoken=YOURTOKEN

And finally, in this third variant of the call we set the wktaction parameter to a value of ‘crop’ causing the resulting image to be cropped to include only the area within the polygon.
https://geointeldemo.azurewebsites.net/api/staticMap?layer=bluesky-ultra&zoom=21&latitude=34.0837&longitude=-118.33447&width=800&height=600&wktaction=crop&wkt=polygon((-118.334267647877%2034.0837817868285,-118.334687397779%2034.0837807345763,-118.334686980325%2034.0837094282591,-118.334686287342%2034.0835857756008,-118.334266542153%2034.0835877621179,-118.334267647877%2034.0837817868285))&authtoken=YOURTOKEN

Alright, on to the code to make all of this happen. the main workflow is in the get() method. In it we:
- Gather all of the parameters that were passed in and set defaults for any that were omitted.
- Calculate the pixel dimension of the bounding box in global pixel coordinates that will fit the desired image
- Calculate the tiles needed to cover that bounding box. the Northwest and southeast tile coordinates.
- Request all of the tiles and stitch them into a single bitmap.
- If a polygon was passed in the WKT parameter, draw it over the image or mask off the image as specified in the wktaction parameter
- Crop the image to the precise pixel size requested in the width and height parameters
- Return the bitmap as a PNG
All of the code below is written in C# and built to run as a web service. The portions related to threading and drawing rely on the .NET framework, but all of this should be easily adapted to any other language and execution environment.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System.Threading;
using System.Web;
using System.Net;
using System.IO;
using System.Drawing;
namespace GICPropertyInfo.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class staticMapController : ControllerBase
{
TileImage[] tileImages;
public int tileSizeInPixels = 256;
public MemoryStream Get()
{
//Get URL parameters
double cntrLatitude = double.Parse(HttpUtility.ParseQueryString(Request.QueryString.ToString()).Get("latitude"));
double cntrLongitude = double.Parse(HttpUtility.ParseQueryString(Request.QueryString.ToString()).Get("longitude"));
string vexcelToken = HttpUtility.ParseQueryString(Request.QueryString.ToString()).Get("authtoken");
if (vexcelToken==null || vexcelToken=="")
return new System.IO.MemoryStream(System.Text.Encoding.UTF8.GetBytes("authtoken is a required parameter."));
int zoom;
string tmpZoom = HttpUtility.ParseQueryString(Request.QueryString.ToString()).Get("zoom");
if (tmpZoom == null || tmpZoom == "")
zoom = 21;
else
zoom = int.Parse(tmpZoom);
int widthInPixels;
string tmpWidth = HttpUtility.ParseQueryString(Request.QueryString.ToString()).Get("width");
if (tmpWidth == null || tmpWidth == "")
widthInPixels = 400;
else
widthInPixels = int.Parse(tmpWidth);
int heightInPixels;
string tmpHeight = HttpUtility.ParseQueryString(Request.QueryString.ToString()).Get("height");
if (tmpHeight == null || tmpHeight == "")
heightInPixels = 400;
else
heightInPixels = int.Parse(tmpHeight);
string layername = HttpUtility.ParseQueryString(Request.QueryString.ToString()).Get("layer");
if (layername ==null || layername=="")
layername = "bluesky-ultra";
string aoiName = HttpUtility.ParseQueryString(Request.QueryString.ToString()).Get("aoi");
if (aoiName == null || aoiName == "")
aoiName = "";
string wktpolygon = HttpUtility.ParseQueryString(Request.QueryString.ToString()).Get("wkt");
if (wktpolygon == null || wktpolygon == "")
wktpolygon = "";
string wktAction = HttpUtility.ParseQueryString(Request.QueryString.ToString()).Get("wktaction");
if (wktAction == null || wktAction == "")
wktAction = "draw";
//Calculate the pixel dimension bounding box
TileSystem.LatLongToPixelXY(cntrLatitude, cntrLongitude, zoom, out int pixelX, out int pixelY);
int minPixelX = pixelX - (widthInPixels / 2);
int maxPixelX = pixelX + (widthInPixels / 2);
int minPixelY = pixelY - (heightInPixels / 2);
int maxPixelY = pixelY + (heightInPixels / 2);
//calculate the tiles needed to cover that bounding box
TileSystem.PixelXYToTileXY(minPixelX, minPixelY, out int minTileX, out int minTileY);
TileSystem.PixelXYToTileXY(maxPixelX, maxPixelY, out int maxTileX, out int maxTileY);
//Get the Pixel location of the Upper Left Tile
TileSystem.TileXYToPixelXY(minTileX, minTileY, out int PixelXorigin, out int PixelYorigin);
//build an image from all the tiles
Image imgUncropped = getPropertyTiles(minTileX, maxTileX- minTileX+1, minTileY, maxTileY - minTileY + 1,
zoom, vexcelToken, layername,aoiName);
//If a polygon was passed in, this is the place to apply it...
//Either Draw it...
if (wktpolygon!="" && wktAction=="draw")
{
List<Coordinate> polygonCoordsLatLong = UtilityFunctions.convertWKTPolygonToCoordinates(wktpolygon);
imgUncropped = renderPolygonToBitmap(polygonCoordsLatLong, PixelXorigin, PixelYorigin, zoom,
imgUncropped, Color.Red, Color.Yellow);
}
//or Crop with it
if (wktpolygon != "" && wktAction == "crop")
{
List<Coordinate> polygonCoordsLatLong = UtilityFunctions.convertWKTPolygonToCoordinates(wktpolygon);
//Convert the polygon's lat/long coordinates to pixel coordinates
PointF[] polygonPixels = new PointF[polygonCoordsLatLong.Count];
PointF thisPointPixel = new PointF();
for (int thiscoord = 0; thiscoord < (polygonCoordsLatLong.Count); thiscoord++)
{
TileSystem.LatLongToPixelXY(polygonCoordsLatLong[thiscoord].latitude, polygonCoordsLatLong[thiscoord].longitude, zoom, out int drawPix1X, out int drawPix1Y);
thisPointPixel.X = drawPix1X - PixelXorigin;
thisPointPixel.Y = drawPix1Y - PixelYorigin;
polygonPixels[thiscoord] = thisPointPixel;
}
//test each pixel in the bitmap and black out any pixel outside of the polygon
Bitmap bmpCropped = new Bitmap(imgUncropped.Width, imgUncropped.Height);
Bitmap gUncropped = (Bitmap)(imgUncropped);
for (int locrow = 0; locrow < imgUncropped.Height; locrow++)
{
for (int loccol = 0; loccol < imgUncropped.Width; loccol++)
{
thisPointPixel = new PointF(loccol, locrow);
bool inside = UtilityFunctions.IsPointInPolygon(polygonPixels, thisPointPixel);
if (inside)
{
bmpCropped.SetPixel(loccol, locrow, gUncropped.GetPixel(loccol, locrow));
} else
{
bmpCropped.SetPixel(loccol, locrow, Color.FromArgb(0, 0, 0));
}
}
}
imgUncropped = bmpCropped;
}
//crop the image down to the desired pixel width and height.
//IMPORTANT: offset it to crop the 'centered' area as expected
Rectangle cropRect = new Rectangle(minPixelX - (minTileX * tileSizeInPixels), minPixelY -(minTileY * tileSizeInPixels),
widthInPixels, heightInPixels);
Bitmap target = new Bitmap(cropRect.Width, cropRect.Height);
Bitmap src = (Bitmap)imgUncropped;
using (Graphics g = Graphics.FromImage(target))
{
g.DrawImage(src, new Rectangle(0, 0, target.Width, target.Height),
cropRect, GraphicsUnit.Pixel);
}
//Now return the reulting Cropped final bitmap as a PNG
MemoryStream memstream = new MemoryStream();
target.Save(memstream, System.Drawing.Imaging.ImageFormat.Png);
memstream.Seek(0, SeekOrigin.Begin);
Response.ContentType = "image/png";
return memstream;
}
//This function takes a List of lat/longs, converts them to pixel coords, and draws them over the map
private Image renderPolygonToBitmap(List<Coordinate> polygonCoordsLatLong,int PixelXorigin, int PixelYorigin ,int zoom,
Image bmp, Color edgeColor, Color vertexColor)
{
var g = Graphics.FromImage(bmp);
for (int thiscoord = 0; thiscoord < (polygonCoordsLatLong.Count - 1); thiscoord++)
{
TileSystem.LatLongToPixelXY(polygonCoordsLatLong[thiscoord].latitude, polygonCoordsLatLong[thiscoord].longitude, zoom, out int drawPix1X, out int drawPix1Y);
TileSystem.LatLongToPixelXY(polygonCoordsLatLong[thiscoord + 1].latitude, polygonCoordsLatLong[thiscoord + 1].longitude, zoom, out int drawPix2X, out int drawPix2Y);
g.DrawLine(new Pen(edgeColor, 2), new Point(drawPix1X - PixelXorigin, drawPix1Y - PixelYorigin), new Point(drawPix2X - PixelXorigin, drawPix2Y - PixelYorigin));
int radius = 3;
g.DrawEllipse(new Pen(vertexColor, 2), (drawPix1X - PixelXorigin) - radius, (drawPix1Y - PixelYorigin) - radius,
radius + radius, radius + radius);
}
return bmp;
}
//this function requests the needed tiles from vexcels tile services. for efficiency, each tile
//is requested on a separate thread to gather all the tiles in parrallel
private Image getPropertyTiles(int originXtile, int numXtiles, int originYtile,
int numYtiles, int originZtile, string VexcelToken, string layerName, string AOIname)
{
//Create a Bitmap for the RGB tiles
var bmp = new System.Drawing.Bitmap(tileSizeInPixels * numXtiles, tileSizeInPixels * numYtiles);
var g = Graphics.FromImage(bmp);
//we'll need a task per tile
int numerOftiles = numYtiles * numXtiles;
Task[] tasks = new Task[numerOftiles];
tileImages = new TileImage[numerOftiles];
int tileCnt = -1;
int colIndx = 0;
int rowIndx = 0;
//First, loop through each desired tile and create a TileImage object to be filled asynchronously
for (int row = originYtile; row < originYtile + numYtiles; row++)
{
colIndx = 0;
for (int col = originXtile; col < originXtile + numXtiles; col++)
{
tileCnt++;
TileImage thisTileImage = new TileImage();
thisTileImage.xIndx = colIndx;
thisTileImage.yindx = rowIndx;
thisTileImage.URL = "https://api.gic.org/images/GetOrthoImageTile/" + layerName + "/" + originZtile + "/" +
col + "/" + row + "/true?format=image%2Fjpeg&token=" + VexcelToken;
if (AOIname != "")
thisTileImage.URL += "&AOI=" + AOIname;
thisTileImage.requestID = "t" +tileCnt;
tileImages[tileCnt] = thisTileImage;
colIndx++;
}
rowIndx++;
}
//Now go fetch eeach tile asynchronously
tileCnt = 0;
DateTime startTime = DateTime.Now;
foreach (TileImage thisTI in tileImages)
{
tasks[tileCnt] = Task.Run(() =>
{
fetchATile(thisTI);
int tileindx = tileCnt;
});
tileCnt++;
}
//wait here for all threads to complete.
Task.WaitAll(tasks);
//loop again to stitch the tiles together into a single bitmap
colIndx = 0;
rowIndx = 0;
tileCnt = -1;
for (int row = originYtile; row < originYtile + numYtiles; row++)
{
colIndx = 0;
for (int col = originXtile; col < originXtile + numXtiles; col++)
{
tileCnt++;
g.DrawImage(tileImages[tileCnt].imgTile, tileSizeInPixels * colIndx, tileSizeInPixels * rowIndx);
colIndx++;
}
rowIndx++;
}
Image imageRGB = bmp;
return imageRGB;
}
private TileImage fetchATile(TileImage thisTileImage)
{
thisTileImage.imgTile = fetchImageFromURL(thisTileImage.URL);
return thisTileImage;
}
private Image fetchImageFromURL(string imageURL)
{
Image imageRGB =null;
if (imageURL == null || imageURL == "")
return imageRGB;
//fetch it
using (WebClient webClient = new WebClient())
{
DateTime startTime = DateTime.Now;
try
{
byte[] data = webClient.DownloadData(imageURL);
MemoryStream memstream = new MemoryStream(data);
imageRGB = Bitmap.FromStream(memstream);
}
catch (Exception ex)
{
imageRGB = null;
}
}
return imageRGB;
}
}
}
and here are two utility functions used by the code above
public static List<Coordinate> convertWKTPolygonToCoordinates(string wktPolygon)
{
wktPolygon = wktPolygon.ToUpper().Trim().Replace("))","");
wktPolygon = wktPolygon.Replace("POLYGON((", "");
wktPolygon = wktPolygon.Replace(" ", " ");
wktPolygon = wktPolygon.Replace(" ", ",");
List<Coordinate> parcelCoordsLatLong;
string[] strC = wktPolygon.Split(',');
parcelCoordsLatLong = new List<Coordinate>();
for (int indx = 0; indx < strC.Length; indx += 2)
{
Coordinate thisCoord = new Coordinate();
thisCoord.longitude = double.Parse(strC[indx]);
thisCoord.latitude = double.Parse(strC[indx + 1]);
parcelCoordsLatLong.Add(thisCoord);
}
return parcelCoordsLatLong;
}
public static bool IsPointInPolygon(PointF[] polygon, PointF testPoint)
{
bool result = false;
int j = polygon.Count() - 1;
for (int i = 0; i < polygon.Count(); i++)
{
if (polygon[i].Y < testPoint.Y && polygon[j].Y >= testPoint.Y || polygon[j].Y < testPoint.Y && polygon[i].Y >= testPoint.Y)
{
if (polygon[i].X + (testPoint.Y - polygon[i].Y) / (polygon[j].Y - polygon[i].Y) * (polygon[j].X - polygon[i].X) < testPoint.X)
{
result = !result;
}
}
j = i;
}
return result;
}
There are a number of conversion methods that were taken from this excellent article on Bing Maps tile system. FOr convenience, I’ve included these helper function here as well:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Text;
namespace GICPropertyInfo
{
static class TileSystem
{
private const double EarthRadius = 6378137;
private const double MinLatitude = -85.05112878;
private const double MaxLatitude = 85.05112878;
private const double MinLongitude = -180;
private const double MaxLongitude = 180;
/// <summary>
/// Clips a number to the specified minimum and maximum values.
/// </summary>
/// <param name="n">The number to clip.</param>
/// <param name="minValue">Minimum allowable value.</param>
/// <param name="maxValue">Maximum allowable value.</param>
/// <returns>The clipped value.</returns>
private static double Clip(double n, double minValue, double maxValue)
{
return Math.Min(Math.Max(n, minValue), maxValue);
}
/// <summary>
/// Determines the map width and height (in pixels) at a specified level
/// of detail.
/// </summary>
/// <param name="levelOfDetail">Level of detail, from 1 (lowest detail)
/// to 23 (highest detail).</param>
/// <returns>The map width and height in pixels.</returns>
public static uint MapSize(int levelOfDetail)
{
return (uint)256 << levelOfDetail;
}
/// <summary>
/// Determines the ground resolution (in meters per pixel) at a specified
/// latitude and level of detail.
/// </summary>
/// <param name="latitude">Latitude (in degrees) at which to measure the
/// ground resolution.</param>
/// <param name="levelOfDetail">Level of detail, from 1 (lowest detail)
/// to 23 (highest detail).</param>
/// <returns>The ground resolution, in meters per pixel.</returns>
public static double GroundResolution(double latitude, int levelOfDetail)
{
latitude = Clip(latitude, MinLatitude, MaxLatitude);
return Math.Cos(latitude * Math.PI / 180) * 2 * Math.PI * EarthRadius / MapSize(levelOfDetail);
}
/// <summary>
/// Determines the map scale at a specified latitude, level of detail,
/// and screen resolution.
/// </summary>
/// <param name="latitude">Latitude (in degrees) at which to measure the
/// map scale.</param>
/// <param name="levelOfDetail">Level of detail, from 1 (lowest detail)
/// to 23 (highest detail).</param>
/// <param name="screenDpi">Resolution of the screen, in dots per inch.</param>
/// <returns>The map scale, expressed as the denominator N of the ratio 1 : N.</returns>
public static double MapScale(double latitude, int levelOfDetail, int screenDpi)
{
return GroundResolution(latitude, levelOfDetail) * screenDpi / 0.0254;
}
/// <summary>
/// Converts a point from latitude/longitude WGS-84 coordinates (in degrees)
/// into pixel XY coordinates at a specified level of detail.
/// </summary>
/// <param name="latitude">Latitude of the point, in degrees.</param>
/// <param name="longitude">Longitude of the point, in degrees.</param>
/// <param name="levelOfDetail">Level of detail, from 1 (lowest detail)
/// to 23 (highest detail).</param>
/// <param name="pixelX">Output parameter receiving the X coordinate in pixels.</param>
/// <param name="pixelY">Output parameter receiving the Y coordinate in pixels.</param>
public static void LatLongToPixelXY(double latitude, double longitude, int levelOfDetail, out int pixelX, out int pixelY)
{
latitude = Clip(latitude, MinLatitude, MaxLatitude);
longitude = Clip(longitude, MinLongitude, MaxLongitude);
double x = (longitude + 180) / 360;
double sinLatitude = Math.Sin(latitude * Math.PI / 180);
double y = 0.5 - Math.Log((1 + sinLatitude) / (1 - sinLatitude)) / (4 * Math.PI);
uint mapSize = MapSize(levelOfDetail);
pixelX = (int)Clip(x * mapSize + 0.5, 0, mapSize - 1);
pixelY = (int)Clip(y * mapSize + 0.5, 0, mapSize - 1);
}
/// <summary>
/// Converts a pixel from pixel XY coordinates at a specified level of detail
/// into latitude/longitude WGS-84 coordinates (in degrees).
/// </summary>
/// <param name="pixelX">X coordinate of the point, in pixels.</param>
/// <param name="pixelY">Y coordinates of the point, in pixels.</param>
/// <param name="levelOfDetail">Level of detail, from 1 (lowest detail)
/// to 23 (highest detail).</param>
/// <param name="latitude">Output parameter receiving the latitude in degrees.</param>
/// <param name="longitude">Output parameter receiving the longitude in degrees.</param>
public static void PixelXYToLatLong(int pixelX, int pixelY, int levelOfDetail, out double latitude, out double longitude)
{
double mapSize = MapSize(levelOfDetail);
double x = (Clip(pixelX, 0, mapSize - 1) / mapSize) - 0.5;
double y = 0.5 - (Clip(pixelY, 0, mapSize - 1) / mapSize);
latitude = 90 - 360 * Math.Atan(Math.Exp(-y * 2 * Math.PI)) / Math.PI;
longitude = 360 * x;
}
/// <summary>
/// Converts pixel XY coordinates into tile XY coordinates of the tile containing
/// the specified pixel.
/// </summary>
/// <param name="pixelX">Pixel X coordinate.</param>
/// <param name="pixelY">Pixel Y coordinate.</param>
/// <param name="tileX">Output parameter receiving the tile X coordinate.</param>
/// <param name="tileY">Output parameter receiving the tile Y coordinate.</param>
public static void PixelXYToTileXY(int pixelX, int pixelY, out int tileX, out int tileY)
{
tileX = pixelX / 256;
tileY = pixelY / 256;
}
/// <summary>
/// Converts tile XY coordinates into pixel XY coordinates of the upper-left pixel
/// of the specified tile.
/// </summary>
/// <param name="tileX">Tile X coordinate.</param>
/// <param name="tileY">Tile Y coordinate.</param>
/// <param name="pixelX">Output parameter receiving the pixel X coordinate.</param>
/// <param name="pixelY">Output parameter receiving the pixel Y coordinate.</param>
public static void TileXYToPixelXY(int tileX, int tileY, out int pixelX, out int pixelY)
{
pixelX = tileX * 256;
pixelY = tileY * 256;
}
/// <summary>
/// Converts tile XY coordinates into a QuadKey at a specified level of detail.
/// </summary>
/// <param name="tileX">Tile X coordinate.</param>
/// <param name="tileY">Tile Y coordinate.</param>
/// <param name="levelOfDetail">Level of detail, from 1 (lowest detail)
/// to 23 (highest detail).</param>
/// <returns>A string containing the QuadKey.</returns>
public static string TileXYToQuadKey(int tileX, int tileY, int levelOfDetail)
{
StringBuilder quadKey = new StringBuilder();
for (int i = levelOfDetail; i > 0; i--)
{
char digit = '0';
int mask = 1 << (i - 1);
if ((tileX & mask) != 0)
{
digit++;
}
if ((tileY & mask) != 0)
{
digit++;
digit++;
}
quadKey.Append(digit);
}
return quadKey.ToString();
}
public static void MetersXYToLatLong(double x, double y, out double latitude, out double longitude)
{
latitude = 90 - 360 * Math.Atan(Math.Exp(-y / EarthRadius)) / Math.PI;
longitude = 360 * x / (EarthRadius * 2 * Math.PI);
}
/// <summary>
/// Converts a QuadKey into tile XY coordinates.
/// </summary>
/// <param name="quadKey">QuadKey of the tile.</param>
/// <param name="tileX">Output parameter receiving the tile X coordinate.</param>
/// <param name="tileY">Output parameter receiving the tile Y coordinate.</param>
/// <param name="levelOfDetail">Output parameter receiving the level of detail.</param>
public static void QuadKeyToTileXY(string quadKey, out int tileX, out int tileY, out int levelOfDetail)
{
tileX = tileY = 0;
levelOfDetail = quadKey.Length;
for (int i = levelOfDetail; i > 0; i--)
{
int mask = 1 << (i - 1);
switch (quadKey[levelOfDetail - i])
{
case '0':
break;
case '1':
tileX |= mask;
break;
case '2':
tileY |= mask;
break;
case '3':
tileX |= mask;
tileY |= mask;
break;
default:
throw new ArgumentException("Invalid QuadKey digit sequence.");
}
}
}
}
}