[FEAT] Improved splitting
This commit is contained in:
316
src/composables/useSpritesheetSplitter.ts
Normal file
316
src/composables/useSpritesheetSplitter.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { ref, computed, type Ref } from 'vue';
|
||||
import type { SpritePreview, SpriteRegion, GridSplitOptions, AutoDetectOptions, SpriteWorkerResponse } from '@/types/spritesheet';
|
||||
|
||||
/**
|
||||
* Composable for spritesheet splitting logic
|
||||
* Handles grid-based and auto-detection sprite extraction
|
||||
*/
|
||||
export function useSpritesheetSplitter() {
|
||||
const isProcessing = ref(false);
|
||||
const previewSprites: Ref<SpritePreview[]> = ref([]);
|
||||
const worker: Ref<Worker | null> = ref(null);
|
||||
|
||||
/**
|
||||
* Split image into grid cells
|
||||
*/
|
||||
async function splitByGrid(img: HTMLImageElement, options: GridSplitOptions): Promise<SpritePreview[]> {
|
||||
const { cellWidth, cellHeight, preserveCellSize, removeEmpty } = options;
|
||||
|
||||
if (cellWidth <= 0 || cellHeight <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cols = Math.floor(img.width / cellWidth);
|
||||
const rows = Math.floor(img.height / cellHeight);
|
||||
const sprites: SpritePreview[] = [];
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const cropCanvas = document.createElement('canvas');
|
||||
const cropCtx = cropCanvas.getContext('2d');
|
||||
|
||||
if (!ctx || !cropCtx) return [];
|
||||
|
||||
canvas.width = cellWidth;
|
||||
canvas.height = cellHeight;
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
ctx.clearRect(0, 0, cellWidth, cellHeight);
|
||||
ctx.drawImage(img, col * cellWidth, row * cellHeight, cellWidth, cellHeight, 0, 0, cellWidth, cellHeight);
|
||||
|
||||
const isEmpty = removeEmpty ? isCanvasEmpty(ctx, cellWidth, cellHeight) : false;
|
||||
|
||||
if (!removeEmpty || !isEmpty) {
|
||||
let url: string;
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let width = cellWidth;
|
||||
let height = cellHeight;
|
||||
|
||||
if (preserveCellSize) {
|
||||
// Keep full cell with transparent padding
|
||||
url = canvas.toDataURL('image/png');
|
||||
} else {
|
||||
// Crop to sprite bounds
|
||||
const bounds = getSpriteBounds(ctx, cellWidth, cellHeight);
|
||||
if (bounds) {
|
||||
x = bounds.x;
|
||||
y = bounds.y;
|
||||
width = bounds.width;
|
||||
height = bounds.height;
|
||||
|
||||
cropCanvas.width = width;
|
||||
cropCanvas.height = height;
|
||||
cropCtx.clearRect(0, 0, width, height);
|
||||
cropCtx.drawImage(canvas, x, y, width, height, 0, 0, width, height);
|
||||
url = cropCanvas.toDataURL('image/png');
|
||||
} else {
|
||||
url = canvas.toDataURL('image/png');
|
||||
}
|
||||
}
|
||||
|
||||
sprites.push({ url, x, y, width, height, isEmpty });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sprites;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect sprites using web worker for irregular layouts
|
||||
*/
|
||||
async function detectSprites(img: HTMLImageElement, options: AutoDetectOptions): Promise<SpritePreview[]> {
|
||||
const { sensitivity, removeEmpty } = options;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return [];
|
||||
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, img.width, img.height);
|
||||
|
||||
// Initialize worker lazily
|
||||
if (!worker.value) {
|
||||
try {
|
||||
worker.value = new Worker(new URL('../workers/irregularSpriteDetection.worker.ts', import.meta.url), { type: 'module' });
|
||||
} catch (error) {
|
||||
console.error('Failed to create worker:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise<SpritePreview[]>(resolve => {
|
||||
const w = worker.value!;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
w.removeEventListener('message', handleMessage);
|
||||
console.warn('Worker timeout');
|
||||
resolve([]);
|
||||
}, 30000);
|
||||
|
||||
const handleMessage = async (e: MessageEvent<SpriteWorkerResponse>) => {
|
||||
clearTimeout(timeout);
|
||||
w.removeEventListener('message', handleMessage);
|
||||
|
||||
if (e.data.type === 'spritesDetected') {
|
||||
const sprites = await processDetectedSprites(img, e.data.sprites, e.data.backgroundColor, removeEmpty);
|
||||
resolve(sprites);
|
||||
}
|
||||
};
|
||||
|
||||
w.addEventListener('message', handleMessage);
|
||||
|
||||
w.postMessage({
|
||||
type: 'detectIrregularSprites',
|
||||
imageData,
|
||||
sensitivity,
|
||||
maxSize: 2048,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process sprites detected by worker into preview format
|
||||
*/
|
||||
async function processDetectedSprites(img: HTMLImageElement, regions: SpriteRegion[], backgroundColor: [number, number, number, number], removeEmpty: boolean): Promise<SpritePreview[]> {
|
||||
if (!regions?.length) return [];
|
||||
|
||||
const sprites: SpritePreview[] = [];
|
||||
const sourceCanvas = document.createElement('canvas');
|
||||
const sourceCtx = sourceCanvas.getContext('2d');
|
||||
const spriteCanvas = document.createElement('canvas');
|
||||
const spriteCtx = spriteCanvas.getContext('2d');
|
||||
|
||||
if (!sourceCtx || !spriteCtx) return [];
|
||||
|
||||
sourceCanvas.width = img.width;
|
||||
sourceCanvas.height = img.height;
|
||||
sourceCtx.drawImage(img, 0, 0);
|
||||
|
||||
for (const region of regions) {
|
||||
const { x, y, width, height } = region;
|
||||
if (width <= 0 || height <= 0) continue;
|
||||
|
||||
spriteCanvas.width = width;
|
||||
spriteCanvas.height = height;
|
||||
spriteCtx.clearRect(0, 0, width, height);
|
||||
spriteCtx.drawImage(sourceCanvas, x, y, width, height, 0, 0, width, height);
|
||||
|
||||
// Remove background color
|
||||
removeBackground(spriteCtx, width, height, backgroundColor);
|
||||
|
||||
const isEmpty = removeEmpty ? isCanvasEmpty(spriteCtx, width, height) : false;
|
||||
|
||||
if (!removeEmpty || !isEmpty) {
|
||||
sprites.push({
|
||||
url: spriteCanvas.toDataURL('image/png'),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
isEmpty,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return sprites;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bounding box of non-transparent pixels
|
||||
*/
|
||||
function getSpriteBounds(ctx: CanvasRenderingContext2D, width: number, height: number): { x: number; y: number; width: number; height: number } | null {
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const data = imageData.data;
|
||||
|
||||
let minX = width,
|
||||
minY = height,
|
||||
maxX = 0,
|
||||
maxY = 0;
|
||||
let hasContent = false;
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const alpha = data[(y * width + x) * 4 + 3];
|
||||
if (alpha > 10) {
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
hasContent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasContent) return null;
|
||||
|
||||
// Add small padding
|
||||
const pad = 1;
|
||||
return {
|
||||
x: Math.max(0, minX - pad),
|
||||
y: Math.max(0, minY - pad),
|
||||
width: Math.min(width - Math.max(0, minX - pad), maxX - minX + 1 + pad * 2),
|
||||
height: Math.min(height - Math.max(0, minY - pad), maxY - minY + 1 + pad * 2),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if canvas is empty (all transparent or same color)
|
||||
*/
|
||||
function isCanvasEmpty(ctx: CanvasRenderingContext2D, width: number, height: number): boolean {
|
||||
const data = ctx.getImageData(0, 0, width, height).data;
|
||||
let allTransparent = true;
|
||||
let allSameColor = true;
|
||||
|
||||
const [firstR, firstG, firstB, firstA] = [data[0], data[1], data[2], data[3]];
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i + 3] > 10) allTransparent = false;
|
||||
if (data[i] !== firstR || data[i + 1] !== firstG || data[i + 2] !== firstB || Math.abs(data[i + 3] - firstA) > 10) {
|
||||
allSameColor = false;
|
||||
}
|
||||
if (!allTransparent && !allSameColor) break;
|
||||
}
|
||||
|
||||
return allTransparent || allSameColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove background color from sprite, making it transparent
|
||||
*/
|
||||
function removeBackground(ctx: CanvasRenderingContext2D, width: number, height: number, bgColor: [number, number, number, number]): void {
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const data = imageData.data;
|
||||
const [bgR, bgG, bgB, bgA] = bgColor;
|
||||
const tolerance = 30;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const a = data[i + 3];
|
||||
if (a < 10) {
|
||||
data[i + 3] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
const rDiff = data[i] - bgR;
|
||||
const gDiff = data[i + 1] - bgG;
|
||||
const bDiff = data[i + 2] - bgB;
|
||||
const dist = Math.sqrt(rDiff * rDiff + gDiff * gDiff + bDiff * bDiff);
|
||||
|
||||
if (dist <= tolerance && Math.abs(a - bgA) <= 40) {
|
||||
data[i + 3] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup worker on unmount
|
||||
*/
|
||||
function cleanup() {
|
||||
if (worker.value) {
|
||||
worker.value.terminate();
|
||||
worker.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate suggested cell size based on image dimensions
|
||||
*/
|
||||
function getSuggestedCellSize(width: number, height: number): { width: number; height: number } {
|
||||
const commonSizes = [256, 192, 128, 96, 64, 48, 32, 16];
|
||||
|
||||
let cellWidth = 64;
|
||||
let cellHeight = 64;
|
||||
|
||||
for (const size of commonSizes) {
|
||||
if (width % size === 0 && width / size <= 16 && width / size >= 1) {
|
||||
cellWidth = size;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const size of commonSizes) {
|
||||
if (height % size === 0 && height / size <= 16 && height / size >= 1) {
|
||||
cellHeight = size;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { width: cellWidth, height: cellHeight };
|
||||
}
|
||||
|
||||
return {
|
||||
isProcessing,
|
||||
previewSprites,
|
||||
splitByGrid,
|
||||
detectSprites,
|
||||
getSuggestedCellSize,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user