arrowHome arrow TechBlog Monday, 19 February 2018  
Main Menu
Home
Quality Snaps
TechBlog
PennCharts
Blog
Family
Auld Frosties
Links
Players
Players (uni)
Players (camb)
Contact Us
Pennchart Recent
pennchart radio

Reading ArcGISServer Cached Tiles from REST / C#

A modification to my print service required the ability to pull tiles from the webserver for reasons of performance when printing cached map services. Using the REST API gets really slow when images get large. Reading the tiles directly is how the Flex/SL/Javascript clients work.

The Challenge
Given an extent and a size, create a single image representing that extent from the cache without resorting to REST export. Weapons of choice: C#, using GDI+ for image manipulation and NewtonSoft for JSON deserialization. This is not a million miles from the arcscripts Map2PDF (http://arcscripts.esri.com/details.asp?dbid=16432), although this is performed entirely server-side, without access to aforementioned ESRI clients.

The algorithm

  1. Read REST Service to discover Resolution & Tiles Origin
  2. Find Overlapping Tiles
  3. Georeference the Tiles
  4. Download the Tiles
  5. Mosaic, Crop & Scale the Tiles

The ESRI Javascript API has a method called TileUtils.getCandidateTileInfo() which is responsible for the first two, so it was mostly a case of viewing the source in a browser and converting to C#.

Read REST Service to discover Resolution & Tiles Origin
By appending &f=json to the standard MapServer layer URL, the REST API returns all the tile information you need. Using Newtonsoft means you don’t have to waste time defining the structure returned. In addition to the origin, the LODs (Levels Of Detail) are what you’re after.


string url = _layerURL + "?f=json";

string jsonResponse = SendGetRequest(url);
JObject o = JObject.Parse(jsonResponse);
_serviceTileWidth = (int)o["tileInfo"]["rows"];
_serviceTileHeight = (int)o["tileInfo"]["cols"];
_dpi = (int)o["tileInfo"]["dpi"];
_originX = Convert.ToDouble(o["tileInfo"]["origin"]["x"].ToString());
_originY = Convert.ToDouble(o["tileInfo"]["origin"]["y"].ToString());

JArray lods = (JArray)o["tileInfo"]["lods"];

To get the applicable LOD for your extent I just converted the javascript (complete with cryptic names):


// Just taken ESRI's Javascript routine and plugging that in...
private JObject GetLevelOfDetail(JArray lods)
{
var wd = _sizeX;
var ht = _sizeY;
var ew = Math.Abs(xmax - xmin);
var eh = Math.Abs(ymax - ymin);
double ed = -1;
double ced;
JObject lod = null;

for (int i = 0; i < lods.Count(); i++)
{
JObject currentLOD = (JObject)lods[i];
double resolution = (double)currentLOD["resolution"];

ced = ew > eh ? Math.Abs(eh - (ht * resolution)) : Math.Abs(ew - (wd * resolution));
if (ed < 0 || ced <= ed)
{
lod = currentLOD;
ed = ced;
}
else
{
break;
}
}

return lod;
}

Find Overlapping Tiles
Now that you’ve read your metadata, you will want to find the tiles overlapping your extent.


// Gets the overlapping (candidate) tile for given coordinate.
// Call this with topleft/bottomright coordinates from your extent.
private void GetCandidateTile(double x, double y)
{
int tw = _serviceTileWidth;
int th = _serviceTileHeight;
double tmw = tw * _resolution;
double tmh = th * _resolution;

// This is the row/column required for the URL
int tr = (int)Math.Floor((_originY - y) / tmh);
int tc = (int)Math.Floor((x - _originX) / tmw);

double tmox = _originX + (tc * tmw);
double tmoy = _originY - (tr * tmh);

// This is how far from the tile origin, in pixels, our coordinates are
_offsetX = (int)Math.Floor(Math.Abs((x - tmox) * (tw / tmw)));//+mv.x
_offsetY = (int)Math.Floor(Math.Abs((y - tmoy) * (th / tmh)));//+mv.y;

// Save for later (yes, I didn't need the intermediate variables but wanted to leave source as-is)
_row = Convert.ToInt32(tr);
_column = Convert.ToInt32(tc);

_tilesOriginX = tmox;
_tilesOriginY = tmoy;
}

Georeference the Tiles
As you can see from the first tile calculation, we are also getting some offsets (in pixels) for where our extent starts in the first tile. We also need to do that for the last (lower right) tile:


private void GetLowerRightOffset(double x, double y)
{
int tr = (int)Math.Floor((_originY - y) / _serviceTileMapHeight);
int tc = (int)Math.Floor((x - _originX) / _serviceTileMapWidth);

double tmox = _originX + (tc * _serviceTileMapWidth) + _serviceTileMapWidth;
double tmoy = _originY - (tr * _serviceTileMapHeight) - _serviceTileMapHeight;

_lowerRightOffsetX = (int)Math.Floor(Math.Abs((tmox - x) * _serviceTileWidth / _serviceTileMapWidth));//+mv.x
_lowerRightOffsetY = (int)Math.Floor(Math.Abs((tmoy - y) * _serviceTileHeight / _serviceTileMapHeight));//+mv.y;

_gridRows = Math.Abs(tr - _row) + 1;
_gridColumns = Math.Abs(tc - _column) + 1;
}

Download the Tiles
Now we have gathered everything we need except for the tiles themselves. Each tile is in the format: /tile/LOD/row/column e.g. http://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/3/300/200

For the actual downloading, I used a boss-worker threading sample from msdn, so I calculate the tiles to download first. Do this by walking along the grid.


public void CalculateTiles()
{
int mapImageX = 0;
int mapImageY = 0;

_tiles2DArray = new CandidateTile[_gridRows, _gridColumns];
// Left-to-right
for (int x = 0; x < _gridColumns; x++)
{
// Top-to-bottom
for (int y = 0; y < _gridRows; y++)
{
// URL for overlapping tile
string tileUrl = _layer.Url + "/tile/" + ((int)_lod["level"]).ToString() + "/" + (_row + y).ToString() +
"/" + (_column + x).ToString();
CandidateTile candidateTile = new CandidateTile(tileUrl, y, x, mapImageX, mapImageY);
candidateTile.SetClipExtent(0, 0, _serviceTileWidth, _serviceTileHeight);
_tiles2DArray[y, x] = candidateTile;

mapImageY += _serviceTileHeight;
}
// Reset Y, we're going left-to-right, top-to-bottom
mapImageY = 0;
// Reset map coords for x
mapImageX += _serviceTileWidth;// (clipXMax - clipXMin);
}

}

Mosaic, Crop & Scale the Tiles

Now for the fun part. Blend all the images together.


Image uncroppedTiles = new Bitmap((_gridColumns)*_serviceTileWidth, (_gridRows)*_serviceTileHeight, PixelFormat.Format24bppRgb);
Graphics g2d = Graphics.FromImage(uncroppedTiles);

foreach (CandidateTile tile in _tiles)
{
Image tileImage = GetImageByUrl(tile.URL);
g2d.DrawImage(tileImage, tile.mapImageX, tile.mapImageY, tileImage.Width, tileImage.Height);
}

This is probably a very inelegant gdi+ solution…but it works


private Image DrawCroppedTiles(Image uncroppedTiles)
{
// This routine should chop the upper left and lower right offsets off, then scale to target size

// Remove the offset so our origin is where it should be. TOCHECK: is the origin where it actually should be?
Image croppedTiles = new Bitmap(uncroppedTiles.Width - _offsetX, uncroppedTiles.Height - _offsetY, PixelFormat.Format32bppArgb);
Graphics g2d = Graphics.FromImage(croppedTiles);
// Create rectangle for displaying image.
Rectangle destRect = new Rectangle(0, 0, croppedTiles.Width, croppedTiles.Height);
Rectangle srcRect = new Rectangle(_offsetX, _offsetY, uncroppedTiles.Width - _offsetX,
uncroppedTiles.Height - _offsetY);
GraphicsUnit units = GraphicsUnit.Pixel;
g2d.DrawImage(uncroppedTiles, destRect, srcRect, units);
croppedTiles.Save(@"c:\temp\cropped.png", System.Drawing.Imaging.ImageFormat.Png);
g2d.Dispose();
Image croppedAtBothEndsImage = new Bitmap(croppedTiles.Width - _lowerRightOffsetX,
croppedTiles.Height - _lowerRightOffsetY,PixelFormat.Format32bppArgb);
g2d = Graphics.FromImage(croppedAtBothEndsImage);
g2d.DrawImageUnscaled(croppedTiles, 0, 0);
croppedAtBothEndsImage.Save(@"c:\temp\cropped_both_ends.png", System.Drawing.Imaging.ImageFormat.Png);
Image croppedClipped = new Bitmap(croppedAtBothEndsImage, _width, _height);

return croppedClipped;
}

Note that the image will get scaled (stretched) and look a little washed out if your cache doesn’t go down to the requisite scale.

No Comments

Add your own comment...

Frosties RandomSnap
kt, pre-projectile-vomiting

kt, pre-projectile-vomiting

top of page