Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
I am the chief technical officer at InVision App, Inc - a prototyping and collaboration platform for designers, built by designers. I also rock out in JavaScript and ColdFusion 24x7.
Meanwhile on Twitter
Loading latest tweet...
Ben Nadel at BFusion / BFLEX 2009 (Bloomington, Indiana) with:

Mapping CSS Sprite Image Coordinates With ColdFusion

By Ben Nadel on

Last week, I got the idea to upload a CSS Sprite image and have ColdFusion scan the image in order to calculate the coordinates and dimensions of individual sprites contained within the image. And, about five attempts later, I had nothing! What I had assumed would be a rather simple task turned into a complex task, fraught with stack overflows, extreme recursion, and hundreds of thousands of operations. Finally, yesterday, I came up with something that worked. It's probably not efficient - but it seems to find all the shapes that I throw at it.


 
 
 

 
  
 
 
 

The processing of the CSS sprite image is broken up across three different ColdFusion component (CFCs). Each of these three components handles a different part of the responsability:

SpriteMapper.cfc

The SpriteMapper.cfc does nothing more than read in the uploaded CSS sprite image and scan it looking for non-transparent pixels. It does so in a top-to-bottom, left-to-right manner, reading in one row of pixels at a time. For every shape pixel (ie. opaque pixel) that it finds, it passes it of the SpriteMap.cfc for shape assignment.

  • <cfcomponent output="false">
  • <cfscript>
  •  
  •  
  • // I handle the reading of the image's underlying pixel data,
  • // determining which pixels are part of the canvas (ie. which
  • // are transparent) and which pixels are part of a shape (ie.
  • // which are opaque).
  • function init( Any image ){
  •  
  • // Get the underlying buffered image so that we can gain
  • // access to the pixel data.
  • variables.bufferedImage = imageGetBufferedImage( image );
  •  
  • // Store our actual map (this takes care of turning opaque
  • // pixels into shapes).
  • variables.spriteMap = new SpriteMap();
  •  
  • // Find all the pixels in the image that may be part of shapes.
  • this._findShapePixels();
  •  
  • // Return this object reference.
  • return( this );
  •  
  • }
  •  
  •  
  • // I traverse the image, looking for pixels that will be part of
  • // shapes (ie. for anything that is NOT transparent).
  • function _findShapePixels(){
  •  
  • // Get the bounding coordinates for our graphic.
  • var width = variables.bufferedImage.getWidth();
  • var height = variables.bufferedImage.getHeight();
  •  
  • // Move down the image.
  • for (var y = 0 ; y < height ; y++ ){
  •  
  • // For each row, move across, looking at each pixel.
  • for (var x = 0 ; x < width ; x++){
  •  
  • // Examine the given pixel to see if it should be
  • // used or ignored.
  • this._processPixel( x, y );
  •  
  • }
  •  
  • }
  •  
  • }
  •  
  •  
  • // I return the mapped shapes.
  • function getShapes(){
  •  
  • return( variables.spriteMap.getShapes() );
  •  
  • }
  •  
  •  
  • // I determine if the given pixel is transparent.
  • function _pixelIsTransparent( Numeric x, Numeric y ){
  •  
  • // Get the numeric representation of the pixel in the image.
  • var pixel = variables.bufferedImage.getRGB(
  • javaCast( "int", x ),
  • javaCast( "int", y )
  • );
  •  
  • // Get the left-most byte of the pixel (which holds the 8-bit
  • // alpha component of the pixel integer).
  • var alphaChannel = bitSHRN( pixel, 24 );
  •  
  • // Consider this pixel transparent if it has full transparency.
  • return( alphaChannel eq 0 );
  •  
  • }
  •  
  •  
  • // I look at a given pixel and determine if it is blank
  • // (transparent) or, if it should be consumed as part of a
  • // sprite shape.
  • function _processPixel( Numeric x, Numeric y ){
  •  
  • // Check to see if this pixel is transparent. If it is
  • // transparent, then it is not part of a shape.
  • if (this._pixelIsTransparent( x, y )){
  •  
  • // Nothing more to do with this pixel.
  • return;
  •  
  • }
  •  
  • // Add this pixel to the map.
  • variables.spriteMap.addPixel( x, y );
  •  
  • }
  •  
  •  
  • </cfscript>
  • </cfcomponent>

SpriteMap.cfc

The SpriteMap.cfc accepts the pixels passed-in from the SpriteMapper.cfc and tries to assign them to a cohesive shape. Due to the nature of the scanning, it is likely that two independent shapes will eventually connect as the image is unfolded. When this happens, the SpriteMap.cfc component has to merge the two shapes.

  • <cfcomponent output="false">
  • <cfscript>
  •  
  •  
  • // I map the incoming opaque pixels to shapes within the sprite
  • // image.
  • function init(){
  •  
  • // I am the collection of known, valid shapes. As shapes
  • // evolve in the map, some may be absorbed by others and will
  • // be removed from this collection.
  • variables.shapes = {};
  •  
  • // Each shape is keyed by its own unique ID.
  • variables.shapeIDIndex = 0;
  •  
  • // Return this object reference.
  • return( this );
  •  
  • };
  •  
  •  
  • // I add the given shape pixel to the map. When a pixel is added,
  • // it is either consumed by an existing shape, or is the means to
  • // start a completely new shape.
  • function addPixel( Numeric x, Numeric y ){
  •  
  • // Check the surrounding pixels for known shapes.
  • var surroundingShapes = this._getSurroundingShapes( x, y );
  •  
  • // Check to see if we have any surrounding shapes.
  • if (arrayLen( surroundingShapes )){
  •  
  • // Add the current pixel to the first known shape.
  • surroundingShapes[ 1 ].addPixel( x, y );
  •  
  • // If this new pixel has bridged the gap between two
  • // known shapes, when this pixel has united the two
  • // shapes into one shape. As such, one of the surrounding
  • // shapes must absorb the other ones.
  • this._mergeShapes( surroundingShapes );
  •  
  • // Nothing mroe to do with this pixel.
  • return;
  •  
  • }
  •  
  • // If we made it this far then we have a completely new shape.
  • // Create the shape with a new, unique ID.
  • var shape = new SpriteShape(
  • ++variables.shapeIDIndex,
  • x,
  • y
  • );
  •  
  • // Store the new shape.
  • variables.shapes[ shape.getID() ] = shape;
  •  
  • }
  •  
  •  
  • // I get the shape that contains the given pixel.
  • function _getShapeContainingPixel( Numeric x, Numeric y ){
  •  
  • // Short-circuit some out-of-bounds cases.
  • if (
  • (x < 0) ||
  • (y < 0)
  • ){
  •  
  • // These are not valid coordinates.
  • return;
  •  
  • }
  •  
  • // Loop over the known shapes to if any of them contain a
  • // pixel with the given coordinates.
  • for (var shapeID in variables.shapes){
  •  
  • var shape = variables.shapes[ shapeID ];
  •  
  • if (shape.containsPixel( x, y )){
  •  
  • return( shape );
  •  
  • }
  •  
  • }
  •  
  • // If we made it this far, no shape was found.
  • return;
  •  
  • }
  •  
  •  
  • // I return the unique set of shapes found in the sprite map.
  • function getShapes(){
  •  
  • var shapes = [];
  •  
  • // Convert the key-based collection into an indexed collection.
  • for (var shapeID in variables.shapes){
  •  
  • arrayAppend(
  • shapes,
  • variables.shapes[ shapeID ]
  • );
  •  
  • }
  •  
  • // Return the shapes.
  • return( shapes );
  •  
  • }
  •  
  •  
  • // I get the shapes in the pixels surrounding the given coordinate.
  • function _getSurroundingShapes( Numeric x, Numeric y ){
  •  
  • // Define the four potentionally known coorindates in relation
  • // to the given pixel. The other direcitons (east and south)
  • // have not yet been explored.
  • //
  • // NOTE: This direcitonal assumption is based on the logic
  • // contained within the scanning alogrithm of the SpriteMapper
  • // component. As such, this part of the code is strongly
  • // coupled to the other component.
  • var pixels = [
  • {
  • x = (x - 1),
  • y = y
  • },
  • {
  • x = (x - 1),
  • y = (y - 1)
  • },
  • {
  • x = x,
  • y = (y - 1)
  • },
  • {
  • x = (x + 1),
  • y = (y - 1)
  • }
  • ];
  •  
  • // Define the shapes that surround the given pixel.
  • var shapes = [];
  •  
  • // Keep track of the IDs of the shapes we found, so that we
  • // keep the collection of returned shapes unique.
  • var shapeIDs = {};
  •  
  • // For a small optimization, we know that if we find a shape
  • // in the first pixel, we can skip the check for the middle
  • // two pixels and jump to the last pixel.
  • var skipMiddlePixels = false;
  •  
  • // Loop over connected pixels to gather regions.
  • for (var i = 1 ; i <= 4 ; i++){
  •  
  • if (skipMiddlePixels && (i < 4)){
  •  
  • continue;
  •  
  • }
  •  
  • var pixel = pixels[ i ];
  •  
  • // Get the parent shape (if there is one).
  • var shape = this._getShapeContainingPixel( pixel.x, pixel.y );
  •  
  • // If we found a shape at the given coordinates,
  • // add it to the collection.
  • if (
  • !isNull( shape ) &&
  • !structKeyExists( shapeIDs, shape.getID() )
  • ){
  •  
  • // Add this shape to the collection.
  • arrayAppend( shapes, shape );
  •  
  • // Cache the ID.
  • shapeIDs[ shape.getID() ] = true;
  •  
  • // If this is the first pixel, then skip directly
  • // to the last pixel.
  • if (i == 1){
  •  
  • skipMiddlePixels = true;
  •  
  • }
  •  
  • }
  •  
  • }
  •  
  • // Return the surrounding shapes.
  • return( shapes );
  •  
  • }
  •  
  •  
  • // I merge the given, connected shapes.
  • function _mergeShapes( shapes ){
  •  
  • // Get the number of shapes to merge.
  • var shapeCount = arrayLen( shapes );
  •  
  • // If there is only one shape, there's nothing to merge.
  • if (shapeCount == 1){
  •  
  • return;
  •  
  • }
  •  
  • // If there is more than one shape, all the secondary shapes
  • // will get merge down into the first shape. Get a reference
  • // to the first shape.
  • var firstShape = shapes[ 1 ];
  •  
  • // Merge all the remaining shapes into the first shape.
  • for (var i = 2 ; i <= shapeCount ; i++){
  •  
  • var oldShape = shapes[ i ];
  •  
  • // Remove this shape from the cache.
  • structDelete( variables.shapes, oldShape.getID() );
  •  
  • // Merge this shape into the first one.
  • firstShape.absorbShape( oldShape );
  •  
  • }
  •  
  • }
  •  
  •  
  • </cfscript>
  • </cfcomponent>

There are optimizations built into the SpriteMap.cfc ColdFusion component that depend on the row-wise scanning performed by the SpriteMapper.cfc ColdFusion component. As such, these two components are algorithmically coupled. I could easily remove the coupling; however, doing so would incur tens-of-thousands of additional operations on the underlying shapes.

SpriteShape.cfc

The SpriteShape.cfc keeps track of all of the pixels contained within a single shape. Furthermore, as pixels are added to a shape, the SpriteShape.cfc ColdFusion component knows to how to grow its own "bounding box" - the coordinates that will eventually be used to determine the CSS coordinates of the sprite image.

  • <cfcomponent output="false">
  • <cfscript>
  •  
  •  
  • // I manage the pixels for a single shape within the sprite. As
  • // pixels are added, they are mapped and the bouding box of the
  • // shape is updated.
  • function init( Numeric id, Numeric x, Numeric y ){
  •  
  • // Store the unique ID of the shape.
  • variables.id = id;
  •  
  • // Store the pixel map, including our first pixel.
  • variables.pixelMap = {
  • "#x#:#y#" = true
  • };
  •  
  • // Set up the initial bounding box defined by the first pixel.
  • variables.minX = x;
  • variables.maxX = (x + 1);
  • variables.minY = y;
  • variables.maxY = (y + 1);
  •  
  • // Return this object reference.
  • return( this );
  •  
  • }
  •  
  •  
  • // I absorb the dimentions of the given shape.
  • function absorbShape( Any shape ){
  •  
  • // Absorb all of the pixels in the underlying map.
  • structAppend(
  • variables.pixelMap,
  • shape.getPixelMap()
  • );
  •  
  • // Get the other shape's boudning box.
  • var boundingBox = shape.getBoundingBox();
  •  
  • // Grow the current bounding box to encompass the new
  • // bounding box of the absorbed shape.
  • variables.minX = min( variables.minX, boundingBox.minX );
  • variables.maxX = max( variables.maxX, boundingBox.maxX );
  • variables.minY = min( variables.minY, boundingBox.minY );
  • variables.maxY = max( variables.maxY, boundingBox.maxY );
  •  
  • }
  •  
  •  
  • // I add the given pixel to the shape.
  • function addPixel( Numeric x, Numeric y ){
  •  
  • // Add it the map.
  • variables.pixelMap[ "#x#:#y#" ] = true;
  •  
  • // Adjust the min/max box coordinates.
  • variables.minX = min( variables.minX, x );
  • variables.maxX = max( variables.maxX, (x + 1) );
  • variables.minY = min( variables.minY, y );
  • variables.maxY = max( variables.maxY, (y + 1) );
  •  
  • }
  •  
  •  
  • // I determine if the given pixel is within the known pixels of
  • // mapped shape.
  • function containsPixel( Numeric x, Numeric y ){
  •  
  • return(
  • structKeyExists( variables.pixelMap, "#x#:#y#" )
  • );
  •  
  • }
  •  
  •  
  • // I get the bounding box for the shape based on the min/max
  • // coordinates of the bounding box.
  • function getBoundingBox(){
  •  
  • var boundingBox = {
  • minX = variables.minX,
  • maxX = variables.maxX,
  • minY = variables.minY,
  • maxY = variables.maxY,
  • width = (variables.maxX - variables.minX),
  • height = (variables.maxY - variables.minY)
  • };
  •  
  • return( boundingBox );
  •  
  • }
  •  
  •  
  • // I return the ID property.
  • function getID(){
  •  
  • return( variables.id );
  •  
  • }
  •  
  •  
  • // I return the underlying pixel map.
  • function getPixelMap(){
  •  
  • return( variables.pixelMap );
  •  
  • }
  •  
  •  
  • </cfscript>
  • </cfcomponent>

To bring this all together, I created a demo page that uploads an image and runs it through the SpriteMapper.cfc. Right now, the SpriteMapper.cfc is hard-coded to look for non-transparent pixels. As such, it only works with transparent PNGs.

Demo.cfm - Our CSS Sprite Mapping Demo

  • <!--- Param the form values. --->
  • <cfparam name="form.submitted" type="boolean" default="false" />
  • <cfparam name="form.sprite" type="string" default="" />
  •  
  •  
  • <!--- ----------------------------------------------------- --->
  • <!--- ----------------------------------------------------- --->
  •  
  •  
  • <!--- Check to see if the form has been submitted. --->
  • <cfif (
  • form.submitted &&
  • len( form.sprite )
  • )>
  •  
  •  
  • <!--- Upload the file. --->
  • <cffile
  • result="upload"
  • action="upload"
  • filefield="sprite"
  • destination="#expandPath( './uploads/' )#"
  • nameconflict="makeunique"
  • />
  •  
  • <!--- Map the spirte elements within the uploading file. --->
  • <cfset spriteMapper = new SpriteMapper(
  • imageNew( "#upload.serverDirectory#/#upload.serverFile#" )
  • ) />
  •  
  •  
  • </cfif>
  •  
  •  
  • <!--- ----------------------------------------------------- --->
  • <!--- ----------------------------------------------------- --->
  •  
  •  
  • <cfcontent type="text/html; charset=utf-8" />
  • <cfoutput>
  •  
  • <!doctype html>
  • <html>
  • <head>
  • <title>CSS Sprite Coordinates</title>
  • </head>
  • <body>
  •  
  • <h1>
  • CSS Sprite Coordinates
  • </h1>
  •  
  • <form
  • method="post"
  • action="#cgi.script_name#"
  • enctype="multipart/form-data">
  •  
  • <input type="hidden" name="submitted" value="true" />
  •  
  • <p>
  • Upload your CSS sprite PNG:<br />
  • <input type="file" name="sprite" size="50" />
  • </p>
  •  
  • <p>
  • <input type="submit" value="Upload" />
  • </p>
  •  
  • </form>
  •  
  •  
  • <!---
  • Check to see if we have our sprite mapper. If so, then
  • we can access the shapes and output the coordinates.
  • --->
  • <cfif !isNull( spriteMapper )>
  •  
  • <h2>
  • Sprite Results:
  • </h2>
  •  
  • <!---
  • To make the output easier to read, we're going to
  • create classes rather than inline styles. As such,
  • we'll need an index counter to make the class
  • names unique.
  • --->
  • <cfset classIndex = 0 />
  •  
  • <!--- Output each shape. --->
  • <cfloop
  • index="shape"
  • array="#spriteMapper.getShapes()#">
  •  
  •  
  • <cfset classIndex++ />
  •  
  • <!--- Get the dimensions and offset of the sprite. --->
  • <cfset box = shape.getBoundingBox() />
  •  
  • <style type="text/css">
  •  
  • ##sprite#classIndex# {
  • background-image: url( "./uploads/#upload.serverFile#" ) ;
  • background-position: -#box.minX#px -#box.minY#px ;
  • background-repeat: no-repeat ;
  • border: 5px solid ##D0D0D0 ;
  • height: #box.height#px ;
  • margin-bottom: 30px ;
  • width: #box.width#px ;
  • }
  •  
  • </style>
  •  
  • <div id="sprite#classIndex#">
  • <br />
  • </div>
  •  
  •  
  • </cfloop>
  •  
  • </cfif>
  •  
  • </body>
  • </html>
  •  
  • </cfoutput>

Running a small CSS sprite through the above page gives us the following output - each shape contained within the Sprite is output as its own Div with its own set of CSS coordinates. Each Div uses the same exact background image (ie. the image we just uploaded):


 
 
 

 
 CSS sprite image coordinates parsed with ColdFusion. 
 
 
 

I'm not 100% pleased with this approach, based purely on the amount of time it takes to process even a moderately sized image. But, like I said above, this was my 5th attempt at simply getting something that worked.



Reader Comments

I just noticed that if you used this on multiple sprite files, the CSS class names would be duplicated (.sprite1, .sprite2, etc). You'd have to go through, identify which # corresponds to each image and manually rename the classes.

What do you recommend to accept a number of PNG images (like from an uploaded ZIP file or static directory) and then automatically generate a sprite file using each file names as the CSS class. (I've seen a couple of different PHP libraries that do this.) I started writing something, but the image generation for laying out multiple-sized images got too tricky.

Reply to this Comment

@James,

You bring up a really good point - my output here wasn't meant to be used directly in a CSS file. I mean, eventually it was; but, I assumed the developer would take the CSS coordinates and integrate them quite manually into whatever CSS they were currently using.

Ultimately, I just wanted a way to find the coordinates of shapes; I hadn't really thought through to integration.

Reply to this Comment

@Ben,

Because the classname isn't descriptive enough, any designer would have to:

1) render the CSS to see what shows up for each class
2) open an image editor and count pixels
3) count the row/column positions in the image and hope that the image naming is consistent

and then rename any conflicting classes.

I'd recommend adding the ability to enter a custom prefix for all classes in the generated sprite.

While a manual renaming process is probably tolerable for small sprites, the generic names would be difficult when using a more complicated jQueryUI-type sprite.
http://jqueryui.com/themeroller/images/?new=ffffff&w=256&h=240&f=png&fltr[]=rcd|256&fltr[]=mask|icons/icons.png

Reply to this Comment

@James,

I see what you're saying. I guess when I had this idea, I was coming from a different use case. I was working on a project that had *existing* CSS sprites. I had to take the existing sprite and add a few images to it. As such, all I was concerned with was easily locating the coordinates and dimensions of the newly added images - then I would manually create my own classes.

That said, I think having a full-fledges solution makes way more sense :D

@Tom,

I have not heard of that - I'll take a look, thanks!

@Aaron,

That looks really cool; though, it seems to freeze up my Mac Book Air for 15 seconds while its calculating things on the fly. I wonder if they are performing the same kind of action, but in Canvas on the client side. I'll have to investigate further. Seems like a perfect place to use something like WebWorkers (which I have yet to play with).

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.