Sprite Sheet Splitter: Connected Component Labeling and Browser-Side ZIP Packaging
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:
- Scan each pixel, find an unvisited non-transparent pixel → starting point of a new sprite
- BFS flood fill, marking all connected non-transparent pixels
- Record the bounding box (minX, minY, maxX, maxY) of the connected region
- 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:
- Local file headers: Metadata + compressed data for each file
- Central directory: Index of all files
- 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#
- Sprites touching edges: Connected component detection treats them as one sprite. Leave sufficient gaps during design
- Semi-transparent overlap: Semi-transparent areas between sprites get assigned to one sprite
- 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