UI and application enhancements
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<Modal :is-open="isOpen" @close="cancel" title="Split Spritesheet">
|
||||
<Modal :is-open="isOpen" @close="cancel" title="Split spritesheet">
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="flex items-center justify-center mb-4">
|
||||
@@ -85,7 +85,7 @@
|
||||
:disabled="previewSprites.length === 0 || isProcessing"
|
||||
data-rybbit-event="spritesheet-split"
|
||||
>
|
||||
Split Spritesheet
|
||||
Split spritesheet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,19 +224,26 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return bounding box with a small padding
|
||||
// Return bounding box with a small padding, ensuring it stays within bounds
|
||||
const bx = Math.max(0, minX - 1);
|
||||
const by = Math.max(0, minY - 1);
|
||||
const bw = Math.min(width - bx, maxX - minX + 3); // +1 for inclusive bounds, +2 for padding
|
||||
const bh = Math.min(height - by, maxY - minY + 3);
|
||||
|
||||
return {
|
||||
x: Math.max(0, minX - 1),
|
||||
y: Math.max(0, minY - 1),
|
||||
width: Math.min(width, maxX - minX + 3), // +1 for inclusive bounds, +2 for padding
|
||||
height: Math.min(height, maxY - minY + 3),
|
||||
x: bx,
|
||||
y: by,
|
||||
width: Math.max(1, bw),
|
||||
height: Math.max(1, bh),
|
||||
};
|
||||
}
|
||||
|
||||
// Split spritesheet manually based on rows and columns
|
||||
async function splitSpritesheet(img: HTMLImageElement, rows: number, columns: number) {
|
||||
const spriteWidth = Math.floor(img.width / columns);
|
||||
const spriteHeight = Math.floor(img.height / rows);
|
||||
const safeColumns = Number.isFinite(columns) && columns > 0 ? Math.floor(columns) : 1;
|
||||
const safeRows = Number.isFinite(rows) && rows > 0 ? Math.floor(rows) : 1;
|
||||
const spriteWidth = Math.max(1, Math.floor(img.width / safeColumns));
|
||||
const spriteHeight = Math.max(1, Math.floor(img.height / safeRows));
|
||||
|
||||
const sprites: SpritePreview[] = [];
|
||||
|
||||
@@ -254,8 +261,8 @@
|
||||
canvas.height = spriteHeight;
|
||||
|
||||
// Split the image into individual sprites
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < columns; col++) {
|
||||
for (let row = 0; row < safeRows; row++) {
|
||||
for (let col = 0; col < safeColumns; col++) {
|
||||
// Clear the canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
@@ -339,35 +346,233 @@
|
||||
const { detectedWidth, detectedHeight } = detectSpriteSize(data, canvas.width, canvas.height);
|
||||
|
||||
if (detectedWidth > 0 && detectedHeight > 0) {
|
||||
const detectedRows = Math.floor(img.height / detectedHeight);
|
||||
const detectedColumns = Math.floor(img.width / detectedWidth);
|
||||
// Sanity thresholds to avoid absurdly tiny tiles/huge counts
|
||||
const MIN_TILE = 8;
|
||||
const MAX_SPRITES = 1024;
|
||||
if (detectedWidth < MIN_TILE || detectedHeight < MIN_TILE) {
|
||||
// Fallback if tile is too small
|
||||
const s = Math.max(1, Math.min(100, sensitivity.value));
|
||||
const divisor = 3 + Math.round(s / 20); // 3..8
|
||||
const estimatedSize = Math.max(MIN_TILE, Math.floor(Math.min(img.width, img.height) / divisor));
|
||||
const estimatedRows = Math.max(1, Math.floor(img.height / estimatedSize));
|
||||
const estimatedColumns = Math.max(1, Math.floor(img.width / estimatedSize));
|
||||
await splitSpritesheet(img, estimatedRows, estimatedColumns);
|
||||
return;
|
||||
}
|
||||
|
||||
const detectedRows = Math.max(1, Math.floor(img.height / detectedHeight));
|
||||
const detectedColumns = Math.max(1, Math.floor(img.width / detectedWidth));
|
||||
|
||||
// If the detected combination is unreasonably high, fallback to estimate
|
||||
if (detectedRows * detectedColumns > MAX_SPRITES) {
|
||||
const s = Math.max(1, Math.min(100, sensitivity.value));
|
||||
const divisor = 3 + Math.round(s / 20); // 3..8
|
||||
const estimatedSize = Math.max(MIN_TILE, Math.floor(Math.min(img.width, img.height) / divisor));
|
||||
const estimatedRows = Math.max(1, Math.floor(img.height / estimatedSize));
|
||||
const estimatedColumns = Math.max(1, Math.floor(img.width / estimatedSize));
|
||||
await splitSpritesheet(img, estimatedRows, estimatedColumns);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the detected size to split the spritesheet
|
||||
await splitSpritesheet(img, detectedRows, detectedColumns);
|
||||
} else {
|
||||
// Fallback to manual splitting with a reasonable guess
|
||||
const estimatedSize = Math.max(16, Math.floor(Math.min(img.width, img.height) / 8));
|
||||
const estimatedRows = Math.floor(img.height / estimatedSize);
|
||||
const estimatedColumns = Math.floor(img.width / estimatedSize);
|
||||
// Fallback to manual splitting with a reasonable guess based on sensitivity
|
||||
const s = Math.max(1, Math.min(100, sensitivity.value));
|
||||
const divisor = 3 + Math.round(s / 20); // 3..8
|
||||
const estimatedSize = Math.max(8, Math.floor(Math.min(img.width, img.height) / divisor));
|
||||
const estimatedRows = Math.max(1, Math.floor(img.height / estimatedSize));
|
||||
const estimatedColumns = Math.max(1, Math.floor(img.width / estimatedSize));
|
||||
|
||||
await splitSpritesheet(img, estimatedRows, estimatedColumns);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to detect sprite size based on transparency patterns
|
||||
// Helper function to detect sprite size based on transparency/color gutters and edge periodicity
|
||||
function detectSpriteSize(data: Uint8ClampedArray, width: number, height: number) {
|
||||
// This is a simplified implementation
|
||||
// A real implementation would be more sophisticated
|
||||
// Map sensitivity (1-100) to thresholds:
|
||||
// Higher sensitivity -> allows stricter background matching and lower gutter proportion thresholds
|
||||
const s = Math.max(1, Math.min(100, sensitivity.value));
|
||||
|
||||
// The sensitivity affects how aggressive we are in detecting patterns
|
||||
const threshold = 100 - sensitivity.value; // Lower threshold = more sensitive
|
||||
// Background/color thresholds
|
||||
const alphaBgThresh = Math.round(255 * (0.15 + (100 - s) * 0.001)); // 15%-25% depending on sensitivity
|
||||
const colorTol = Math.round(10 + (100 - s) * 0.8); // 10..90 Euclidean approx
|
||||
const gutterPropThresh = 0.92 - s * 0.004; // 0.92 down to ~0.52
|
||||
|
||||
// For now, return a simple estimate based on image size
|
||||
// In a real implementation, we would analyze the image data to find patterns
|
||||
return {
|
||||
detectedWidth: 0, // Return 0 to fall back to the manual method
|
||||
detectedHeight: 0,
|
||||
};
|
||||
function getPixel(x: number, y: number) {
|
||||
const idx = (y * width + x) * 4;
|
||||
return [data[idx], data[idx + 1], data[idx + 2], data[idx + 3]] as [number, number, number, number];
|
||||
}
|
||||
|
||||
// Estimate background color from corners (median of corners)
|
||||
const corners: [number, number, number, number][] = [getPixel(0, 0), getPixel(width - 1, 0), getPixel(0, height - 1), getPixel(width - 1, height - 1)];
|
||||
function median(arr: number[]) {
|
||||
const a = arr.slice().sort((a, b) => a - b);
|
||||
const mid = (a.length - 1) / 2;
|
||||
return (a[Math.floor(mid)] + a[Math.ceil(mid)]) / 2;
|
||||
}
|
||||
const bg = [median(corners.map(c => c[0])), median(corners.map(c => c[1])), median(corners.map(c => c[2])), median(corners.map(c => c[3]))] as [number, number, number, number];
|
||||
|
||||
function isBg(r: number, g: number, b: number, a: number) {
|
||||
if (a <= alphaBgThresh) return true;
|
||||
const dr = r - bg[0];
|
||||
const dg = g - bg[1];
|
||||
const db = b - bg[2];
|
||||
// Use Manhattan distance approximation for speed
|
||||
const manhattan = Math.abs(dr) + Math.abs(dg) + Math.abs(db);
|
||||
// Normalize approx to 0..~765 and compare to scaled tolerance
|
||||
return manhattan <= colorTol * 3;
|
||||
}
|
||||
|
||||
// Sample stride to speed up scanning large sheets
|
||||
const rowSample = Math.max(1, Math.floor(height / 64));
|
||||
const colSample = Math.max(1, Math.floor(width / 64));
|
||||
|
||||
// Compute background proportion per column and row
|
||||
const colBgProp: number[] = new Array(width).fill(0);
|
||||
for (let x = 0; x < width; x++) {
|
||||
let bgCount = 0;
|
||||
let total = 0;
|
||||
for (let y = 0; y < height; y += rowSample) {
|
||||
const [r, g, b, a] = getPixel(x, y);
|
||||
if (isBg(r, g, b, a)) bgCount++;
|
||||
total++;
|
||||
}
|
||||
colBgProp[x] = total > 0 ? bgCount / total : 1;
|
||||
}
|
||||
|
||||
const rowBgProp: number[] = new Array(height).fill(0);
|
||||
for (let y = 0; y < height; y++) {
|
||||
let bgCount = 0;
|
||||
let total = 0;
|
||||
for (let x = 0; x < width; x += colSample) {
|
||||
const [r, g, b, a] = getPixel(x, y);
|
||||
if (isBg(r, g, b, a)) bgCount++;
|
||||
total++;
|
||||
}
|
||||
rowBgProp[y] = total > 0 ? bgCount / total : 1;
|
||||
}
|
||||
|
||||
function extractRuns(bgProp: number[]): { emptyRuns: [number, number][]; segSizes: number[] } {
|
||||
const emptyRuns: [number, number][] = [];
|
||||
const segSizes: number[] = [];
|
||||
let inEmpty = false;
|
||||
let runStart = 0;
|
||||
let lastSeparatorEnd = -1;
|
||||
for (let i = 0; i < bgProp.length; i++) {
|
||||
const isEmpty = bgProp[i] >= gutterPropThresh;
|
||||
if (isEmpty && !inEmpty) {
|
||||
inEmpty = true;
|
||||
runStart = i;
|
||||
if (lastSeparatorEnd >= 0) {
|
||||
const seg = runStart - lastSeparatorEnd - 1;
|
||||
if (seg > 0) segSizes.push(seg);
|
||||
}
|
||||
} else if (!isEmpty && inEmpty) {
|
||||
inEmpty = false;
|
||||
emptyRuns.push([runStart, i - 1]);
|
||||
lastSeparatorEnd = i - 1;
|
||||
}
|
||||
}
|
||||
if (inEmpty) {
|
||||
emptyRuns.push([runStart, bgProp.length - 1]);
|
||||
lastSeparatorEnd = bgProp.length - 1;
|
||||
}
|
||||
// Trailing segment after last empty run
|
||||
if (lastSeparatorEnd >= 0 && lastSeparatorEnd < bgProp.length - 1) {
|
||||
const seg = bgProp.length - 1 - lastSeparatorEnd;
|
||||
if (seg > 0) segSizes.push(seg);
|
||||
}
|
||||
return { emptyRuns, segSizes };
|
||||
}
|
||||
|
||||
function modeWithTolerance(values: number[], tol = 2): number {
|
||||
if (values.length === 0) return 0;
|
||||
values.sort((a, b) => a - b);
|
||||
let bestCount = 0;
|
||||
let bestVal = values[0];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const base = values[i];
|
||||
let count = 1;
|
||||
for (let j = i + 1; j < values.length; j++) {
|
||||
if (Math.abs(values[j] - base) <= tol) count++;
|
||||
else break;
|
||||
}
|
||||
if (count > bestCount) {
|
||||
bestCount = count;
|
||||
bestVal = base;
|
||||
}
|
||||
}
|
||||
return bestVal;
|
||||
}
|
||||
|
||||
const colRuns = extractRuns(colBgProp);
|
||||
const rowRuns = extractRuns(rowBgProp);
|
||||
|
||||
let detectedWidth = modeWithTolerance(colRuns.segSizes, 2);
|
||||
let detectedHeight = modeWithTolerance(rowRuns.segSizes, 2);
|
||||
|
||||
// Fallback: use edge periodicity via autocorrelation if gutters not found
|
||||
function edgeAutocorrLength(axis: 'x' | 'y'): number {
|
||||
const maxLen = axis === 'x' ? width : height;
|
||||
const otherLen = axis === 'x' ? height : width;
|
||||
const sampleStepMajor = Math.max(1, Math.floor(maxLen / 512));
|
||||
const sampleStepMinor = Math.max(1, Math.floor(otherLen / 64));
|
||||
const energy: number[] = new Array(maxLen).fill(0);
|
||||
if (axis === 'x') {
|
||||
for (let x = 0; x < maxLen - 1; x += sampleStepMajor) {
|
||||
let e = 0;
|
||||
for (let y = 0; y < otherLen; y += sampleStepMinor) {
|
||||
const [r1, g1, b1, a1] = getPixel(x, y);
|
||||
const [r2, g2, b2, a2] = getPixel(x + 1, y);
|
||||
e += Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2) + Math.abs(a1 - a2);
|
||||
}
|
||||
energy[x] = e;
|
||||
}
|
||||
} else {
|
||||
for (let y = 0; y < maxLen - 1; y += sampleStepMajor) {
|
||||
let e = 0;
|
||||
for (let x = 0; x < otherLen; x += sampleStepMinor) {
|
||||
const [r1, g1, b1, a1] = getPixel(x, y);
|
||||
const [r2, g2, b2, a2] = getPixel(x, y + 1);
|
||||
e += Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2) + Math.abs(a1 - a2);
|
||||
}
|
||||
energy[y] = e;
|
||||
}
|
||||
}
|
||||
const minTile = Math.max(3, Math.floor(Math.min(maxLen / 32, 128)));
|
||||
const maxTile = Math.max(minTile + 1, Math.floor(Math.min(maxLen / 2, 512)));
|
||||
let bestLag = 0;
|
||||
let bestVal = -Infinity;
|
||||
for (let lag = minTile; lag <= maxTile; lag++) {
|
||||
let sum = 0;
|
||||
for (let i = 0; i + lag < energy.length; i++) {
|
||||
const e1 = energy[i] || 0;
|
||||
const e2 = energy[i + lag] || 0;
|
||||
sum += e1 * e2;
|
||||
}
|
||||
if (sum > bestVal) {
|
||||
bestVal = sum;
|
||||
bestLag = lag;
|
||||
}
|
||||
}
|
||||
return bestLag;
|
||||
}
|
||||
|
||||
if (detectedWidth <= 0 || detectedWidth > width) {
|
||||
const lagX = edgeAutocorrLength('x');
|
||||
if (lagX > 0 && lagX <= width) detectedWidth = lagX;
|
||||
}
|
||||
if (detectedHeight <= 0 || detectedHeight > height) {
|
||||
const lagY = edgeAutocorrLength('y');
|
||||
if (lagY > 0 && lagY <= height) detectedHeight = lagY;
|
||||
}
|
||||
|
||||
// Sanity checks
|
||||
if (!Number.isFinite(detectedWidth) || detectedWidth <= 0 || detectedWidth > width) detectedWidth = 0;
|
||||
if (!Number.isFinite(detectedHeight) || detectedHeight <= 0 || detectedHeight > height) detectedHeight = 0;
|
||||
|
||||
return { detectedWidth, detectedHeight };
|
||||
}
|
||||
|
||||
// Check if a canvas is empty (all transparent or same color)
|
||||
|
||||
Reference in New Issue
Block a user