Sprite Sheet Splitter: Connected Component Labeling and Browser-Side ZIP Packaging#

When working on game development or taking over legacy projects, you often encounter sprite sheets: dozens of small images crammed into one large texture, no metadata, just transparent gaps separating them. The designer says “source files are lost, you’ll have to cut them yourself.”

The traditional approach is manual slicing in Photoshop—tedious and error-prone. Let’s automate this.

The Core Problem: What Is “One Sprite”?#

Sprite sheets have a key characteristic: each small image is separated by transparent gaps. In other words, a connected region of non-transparent pixels is one sprite.

This leads us to a classic algorithm: Connected Component Labeling.

BFS Implementation for Connected Component Detection#

Algorithm approach:

  1. Scan each pixel, find an unvisited non-transparent pixel → starting point of a new sprite
  2. BFS flood fill, marking all connected non-transparent pixels
  3. Record the bounding box (minX, minY, maxX, maxY) of the connected region
  4. Continue scanning for the next unvisited non-transparent pixel
function splitByTransparentGap(img: HTMLImageElement): SpriteResult[] {
  const canvas = document.createElement('canvas')
  canvas.width = img.width
  canvas.height = img.height
  const ctx = canvas.getContext('2d')!
  ctx.drawImage(img, 0, 0)

  const imageData = ctx.getImageData(0, 0, img.width, img.height)
  const { data } = imageData
  const w = img.width, h = img.height
  
  // Visited marker
  const visited = new Uint8Array(w * h)
  const results: SpriteResult[] = []
  
  // Check if pixel is non-transparent (alpha > 32)
  function isNonTransparent(x: number, y: number): boolean {
    if (x < 0 || x >= w || y < 0 || y >= h) return false
    const idx = (y * w + x) * 4
    return data[idx + 3] > 32  // alpha channel
  }

  // BFS flood fill, returns bounding box
  function floodFill(startX: number, startY: number) {
    const queue: [number, number][] = [[startX, startY]]
    visited[startY * w + startX] = 1
    
    let minX = startX, minY = startY, maxX = startX, maxY = startY
    
    while (queue.length > 0) {
      const [x, y] = queue.shift()!
      
      // Update bounding box
      if (x < minX) minX = x
      if (y < minY) minY = y
      if (x > maxX) maxX = x
      if (y > maxY) maxY = y
      
      // 4-neighbor expansion
      const neighbors = [[x-1, y], [x+1, y], [x, y-1], [x, y+1]]
      for (const [nx, ny] of neighbors) {
        if (isNonTransparent(nx, ny) && !visited[ny * w + nx]) {
          visited[ny * w + nx] = 1
          queue.push([nx, ny])
        }
      }
    }
    
    return { minX, minY, maxX, maxY }
  }

  // Scan the entire image
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      if (isNonTransparent(x, y) && !visited[y * w + x]) {
        const bounds = floodFill(x, y)
        // Crop original image based on bounding box
        results.push(cropSprite(canvas, bounds))
      }
    }
  }

  return results
}

Why BFS Instead of Recursion?#

Using recursive DFS would be limited by call stack size. A 2048×2048 sprite sheet could have a sprite with hundreds of thousands of pixels in extreme cases, causing stack overflow.

BFS uses a queue to store pending pixels, keeping memory可控. In practice, processing 2000×2000 images works smoothly.

Threshold Selection: How Transparent Is “Transparent”?#

Sprite sheets may have semi-transparent edges (anti-aliasing). alpha > 0 treats edge noise as sprites, while alpha > 255 misses semi-transparent areas.

The sweet spot is alpha > 32, roughly 12% opacity threshold. Adjust based on your image characteristics.

Grid Split Mode#

Some sprite sheets are arranged in neat grids, no need for connected component detection. Simply divide by row and column count:

function splitByGrid(img: HTMLImageElement, cols: number, rows: number) {
  const cellW = Math.floor(img.width / cols)
  const cellH = Math.floor(img.height / rows)
  
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      const sx = c * cellW
      const sy = r * cellH
      // Crop each cell
      cropCell(img, sx, sy, cellW, cellH)
    }
  }
}

Grid mode works well for:

  • Tilesets
  • Regular icon sets
  • Animation frame sequences

Browser-Side ZIP Packaging#

After splitting, users want to download all sprites at once. Downloading individually is painful—ZIP packaging makes sense.

Browsers don’t have native ZIP APIs, but the ZIP format is simple enough to implement manually.

ZIP File Format#

A ZIP file consists of three parts:

  1. Local file headers: Metadata + compressed data for each file
  2. Central directory: Index of all files
  3. End of central directory record (EOCD): File end marker

We use Store mode (no compression), storing PNG data directly:

function createZip(files: { name: string; data: Uint8Array }[]): Blob {
  const parts: ArrayBuffer[] = []
  const centralDir: ArrayBuffer[] = []
  let offset = 0
  
  for (const file of files) {
    const nameBytes = new TextEncoder().encode(file.name)
    const crc = crc32(file.data)
    const size = file.length
    
    // Local file header (30 bytes + filename)
    const header = new ArrayBuffer(30 + nameBytes.length)
    const view = new DataView(header)
    view.setUint32(0, 0x04034b50, true)  // Signature
    view.setUint16(4, 20, true)           // Version
    view.setUint16(6, 0, true)            // Flags
    view.setUint16(8, 0, true)            // Store mode
    view.setUint32(14, crc, true)         // CRC32
    view.setUint32(18, size, true)        // Compressed size
    view.setUint32(22, size, true)        // Original size
    view.setUint16(26, nameBytes.length, true)
    new Uint8Array(header).set(nameBytes, 30)
    
    parts.push(header)
    parts.push(file.data.buffer)
    
    // Record Central directory entry...
    offset += 30 + nameBytes.length + size
  }
  
  // Concatenate Central directory + EOCD...
  return new Blob([concat(parts)], { type: 'application/zip' })
}

CRC32 Checksum#

ZIP requires each file to have a CRC32 checksum. The algorithm is standard:

function crc32(data: Uint8Array): number {
  let crc = 0xFFFFFFFF
  for (let i = 0; i < data.length; i++) {
    crc ^= data[i]
    for (let j = 0; j < 8; j++) {
      crc = (crc >>> 1) ^ (crc & 1 ? 0xEDB88320 : 0)
    }
  }
  return (crc ^ 0xFFFFFFFF) >>> 0
}

The polynomial 0xEDB88320 is the standard CRC-32 parameter—no need to memorize, just look it up.

Performance Optimization#

1. Avoid Frequent Canvas Creation#

Creating a new Canvas for each sprite crop triggers GC. Better approach: reuse a single offscreen Canvas.

2. Minimum Area Filtering#

Connected component detection may identify noise (1-2 pixel blocks). Set a minimum area threshold (e.g., 16 pixels) to filter:

if (pixelCount < minArea) return null

3. requestAnimationFrame for Frame Splitting#

Processing huge images may block the UI. Use requestAnimationFrame to split work across frames, or move computation to a Web Worker.

Edge Cases#

  1. Sprites touching edges: Connected component detection treats them as one sprite. Leave sufficient gaps during design
  2. Semi-transparent overlap: Semi-transparent areas between sprites get assigned to one sprite
  3. Huge sprite sheets: Images over 4096×4096 may trigger browser memory limits

Real Results#

Based on these algorithms, I built: Sprite Splitter

Features:

  • Automatic transparent gap detection
  • Grid splitting mode
  • Minimum area filtering
  • Batch ZIP download

Tested with 2000×2000 images, splitting 100+ sprites—all processed in the browser, no server uploads.


Related tools: Image Compress | Image Crop