← Files ModRetro Chromatic PluginARCHIVED FILE
skills/modretro-chromatic-pixel-art/scripts/evaluate-grid.mjs
44 KB · Oct 3, 2026 · 06:38 UTC
import { readFile, writeFile, mkdir, lstat } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { resolve, parse as parsePath } from 'node:path';
import { pathToFileURL } from 'node:url';
// Help remains available before a lean payload's runtime is configured.
let PNG;
function loadPng() {
if (PNG) return;
const require = createRequire(process.env.GB_STUDIO_RUNTIME_ROOT
? resolve(process.env.GB_STUDIO_RUNTIME_ROOT, 'package.json')
: import.meta.url);
({ PNG } = require('pngjs'));
}
const defaults = ['transparent=#ffffff', 'ink=#22303a', 'light=#8ebe67', 'mid=#4d8751'];
const maximumSearchCandidates = 25_000;
const clamp = (value, minimum, maximum) => Math.min(maximum, Math.max(minimum, value));
const mean = values => values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
const rounded = (value, digits = 2) => Number(value.toFixed(digits));
const percent = (numerator, denominator) => denominator ? rounded(numerator / denominator * 100) : 0;
const distanceSquared = (a, b) => (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2;
const hex = rgb => '#' + rgb.slice(0, 3).map(n => n.toString(16).padStart(2, '0')).join('');
function parseColor(specification, index) {
const [possibleName, possibleColor] = specification.split('=');
const name = possibleColor ? possibleName : `color${index}`;
const color = possibleColor ?? possibleName;
const match = color.match(/^#?([\da-f]{6})$/i);
if (!match) throw new Error(`Not a six-digit color: ${specification}`);
const rgb = match[1].match(/../g).map(part => Number.parseInt(part, 16));
return { name, hex: hex(rgb), rgb };
}
function cli(argv) {
const result = { inputs: [] };
for (let index = 0; index < argv.length; index++) {
const argument = argv[index];
if (argument === '--help' || argument === '-h') result.help = true;
else if (argument.startsWith('--')) {
const key = argument.slice(2);
if (!['out', 'name', 'palette', 'grid', 'crop', 'cells', 'guide', 'transparent', 'tolerance', 'phase-search', 'pitch-search', 'margin'].includes(key)) throw new Error(`Unknown option ${argument}`);
const value = argv[++index];
if (value === undefined) throw new Error(`Missing value for ${argument}`);
result[key] = value;
} else result.inputs.push(argument);
}
return result;
}
function searchBound(raw, name) {
if (raw === undefined) return undefined;
const value = typeof raw === 'string' && raw.trim() === '' ? NaN : Number(raw);
if (!Number.isFinite(value) || value < 0) throw new Error(`--${name} must be a finite nonnegative number`);
return value;
}
function guideMatch(r, g, b, a, guide, loose) {
if (a < 200) return false;
if (guide === 'black') return Math.max(r, g, b) <= (loose ? 52 : 26) && Math.max(r, g, b) - Math.min(r, g, b) <= (loose ? 18 : 15);
return loose ? r >= 155 && b >= 150 && g <= 130 && Math.min(r, b) - g >= 55 : r >= 215 && b >= 200 && g <= 70;
}
function smoothProfile(profile, radius) {
return profile.map((value, index) => {
let sum = 0; let count = 0;
for (let pos = Math.max(0, index - radius); pos <= Math.min(profile.length - 1, index + radius); pos++) { sum += profile[pos]; count++; }
return sum / count;
});
}
function searchOffsets(radius, step) {
// A fractional search radius need not land on zero when stepped from -radius.
// Always test the caller's expected grid before nearby alternatives.
const values = [0];
for (let offset = -radius; offset <= radius + step / 10; offset += step) {
if (offset !== 0) values.push(offset);
}
return values;
}
function profiles(png, expected, guide, cells) {
const { width, height, data } = png;
const xBounds = [clamp(Math.round(expected.x), 0, width - 1), clamp(Math.round(expected.x + cells * expected.pitchX), 0, width - 1)];
const yBounds = [clamp(Math.round(expected.y), 0, height - 1), clamp(Math.round(expected.y + cells * expected.pitchY), 0, height - 1)];
const columns = { strict: new Array(width).fill(0), loose: new Array(width).fill(0) };
const rows = { strict: new Array(height).fill(0), loose: new Array(height).fill(0) };
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
if (!(y >= yBounds[0] && y <= yBounds[1]) && !(x >= xBounds[0] && x <= xBounds[1])) continue;
const i = (y * width + x) * 4;
const strict = guideMatch(data[i], data[i + 1], data[i + 2], data[i + 3], guide, false) ? 1 : 0;
const loose = guideMatch(data[i], data[i + 1], data[i + 2], data[i + 3], guide, true) ? 1 : 0;
if (y >= yBounds[0] && y <= yBounds[1]) { columns.strict[x] += strict; columns.loose[x] += loose; }
if (x >= xBounds[0] && x <= xBounds[1]) { rows.strict[y] += strict; rows.loose[y] += loose; }
}
for (const values of Object.values(columns)) for (let x = 0; x < width; x++) values[x] /= yBounds[1] - yBounds[0] + 1;
for (const values of Object.values(rows)) for (let y = 0; y < height; y++) values[y] /= xBounds[1] - xBounds[0] + 1;
return { columns, rows, xBounds, yBounds };
}
function detectAxis(profiles, origin, pitch, cells, scale, options) {
const smooth = smoothProfile(profiles.loose, Math.max(1, Math.round(1.5 * scale)));
const coverageAt = position => smooth[clamp(Math.round(position), 0, smooth.length - 1)];
const phaseSearch = options.phaseSearch ?? Math.max(2, Math.min(30 * scale, pitch * 0.46));
const pitchSearch = options.pitchSearch ?? Math.max(1, 5 * scale);
const step = Math.max(0.25, 0.5 * scale);
const candidateBound = radius => Math.floor((2 * radius + step / 10) / step) + 2;
if (candidateBound(phaseSearch) * candidateBound(pitchSearch) > maximumSearchCandidates) {
throw new Error(`Grid search exceeds the ${maximumSearchCandidates}-candidate budget per axis; reduce search bounds or correct --grid`);
}
let best = { merit: -Infinity };
const phases = searchOffsets(phaseSearch, step);
const pitches = searchOffsets(pitchSearch, step);
for (const phase of phases) for (const delta of pitches) {
const candidateOrigin = origin + phase; const candidatePitch = pitch + delta;
if (candidateOrigin < 0 || candidateOrigin + candidatePitch * cells > smooth.length - 1 || candidatePitch < 4) continue;
const coverages = Array.from({ length: cells + 1 }, (_, line) => coverageAt(candidateOrigin + candidatePitch * line));
const worst = [...coverages].sort((a, b) => a - b).slice(0, Math.max(1, Math.ceil(coverages.length / 4)));
const rawMerit = mean(coverages) * 0.76 + mean(worst) * 0.24;
const regularization = (Math.abs(phase) / Math.max(phaseSearch, 1) + Math.abs(delta) / Math.max(pitchSearch, 1)) * 0.006;
if (rawMerit - regularization > best.merit) best = { merit: rawMerit - regularization, rawMerit, origin: candidateOrigin, pitch: candidatePitch };
}
if (!Number.isFinite(best.merit)) best = { merit: 0, rawMerit: 0, origin, pitch };
const lines = [];
for (let line = 0; line <= cells; line++) {
const expectedPosition = best.origin + best.pitch * line;
const search = Math.max(3, Math.round(best.pitch * 0.20));
const searchStart = Math.max(0, Math.round(expectedPosition) - search);
const searchEnd = Math.min(smooth.length - 1, Math.round(expectedPosition) + search);
let local = searchStart;
// Smoothing is useful for the periodic fit, but must never place an actual guide
// on a neighboring low-coverage pixel when a thin guide creates a smoothed plateau.
// Search the unsmoothed projection first, using expected phase only for exact ties.
for (let position = searchStart + 1; position <= searchEnd; position++) {
if (profiles.loose[position] > profiles.loose[local] || (profiles.loose[position] === profiles.loose[local] && Math.abs(position - expectedPosition) < Math.abs(local - expectedPosition))) local = position;
}
const peak = profiles.loose[local];
const threshold = Math.max(0.35, peak * 0.65);
let first = local; let last = local;
if (peak >= threshold) {
while (first > 0 && profiles.loose[first - 1] >= threshold && local - first <= best.pitch / 3) first--;
while (last + 1 < smooth.length && profiles.loose[last + 1] >= threshold && last - local <= best.pitch / 3) last++;
const midpoint = (first + last) / 2;
// Prefer the center of an equally strong run, but retain the actual raw peak.
for (let position = first; position <= last; position++) {
if (profiles.loose[position] !== peak) continue;
const currentDistance = Math.abs(local - midpoint); const candidateDistance = Math.abs(position - midpoint);
if (candidateDistance < currentDistance || (candidateDistance === currentDistance && Math.abs(position - expectedPosition) < Math.abs(local - expectedPosition))) local = position;
}
}
lines.push({ line, predictedPosition: rounded(expectedPosition), observedPosition: local, displacement: rounded(local - expectedPosition), looseCoveragePercent: percent(profiles.loose[local], 1), strictCoveragePercent: percent(profiles.strict[local], 1), visibleWidth: peak >= threshold ? last - first + 1 : null, visibleStart: peak >= threshold ? first : null, visibleEnd: peak >= threshold ? last : null });
}
const strong = lines.filter(line => line.looseCoveragePercent >= 55).length;
const likely = best.rawMerit >= 0.43 && strong >= Math.ceil((cells + 1) * 0.65);
return { likely, origin: rounded(likely ? best.origin : origin), pitch: rounded(likely ? best.pitch : pitch), fitOrigin: rounded(best.origin), fitPitch: rounded(best.pitch), fitMeanAndLowCoveragePercent: percent(best.rawMerit, 1), observedLineMethod: 'Maximum unsmoothed loose projection within the fitted-line neighborhood; ties prefer the measured band center, then the fitted phase. Existing line thresholds are unchanged.', strongLines: strong, totalLines: cells + 1, maxLocalDisplacement: rounded(Math.max(...lines.filter(line => line.looseCoveragePercent >= 55).map(line => Math.abs(line.displacement)), 0)), medianVisibleWidth: (() => { const widths = lines.map(line => line.visibleWidth).filter(width => width !== null).sort((a, b) => a - b); return widths.length ? widths[Math.floor(widths.length / 2)] : null; })(), lines };
}
function nearestColor(rgb, palette) {
let index = 0; let distance = Infinity;
for (let candidate = 0; candidate < palette.length; candidate++) {
const next = distanceSquared(rgb, palette[candidate].rgb);
if (next < distance) { distance = next; index = candidate; }
}
return { index, distance: Math.sqrt(distance) };
}
function transitionColor(rgb, colors) {
for (let i = 0; i < colors.length; i++) for (let j = i + 1; j < colors.length; j++) {
const a = colors[i]; const b = colors[j];
const span = b.map((value, channel) => value - a[channel]);
const lengthSquared = span.reduce((sum, value) => sum + value * value, 0);
if (lengthSquared < 2500) continue;
const t = span.reduce((sum, value, channel) => sum + value * (rgb[channel] - a[channel]), 0) / lengthSquared;
if (t < 0.15 || t > 0.85) continue;
const interpolated = a.map((value, channel) => value + span[channel] * t);
if (distanceSquared(rgb, interpolated) <= 18 ** 2) return true;
}
return false;
}
function scanAlpha(png) {
const { data, width, height } = png; let empty = 0; let partial = 0; let opaque = 0;
const alphas = new Set(); const rgbaColors = new Set();
for (let index = 0; index < data.length; index += 4) {
const alpha = data[index + 3]; alphas.add(alpha);
if (alpha === 0) empty++; else if (alpha === 255) opaque++; else partial++;
rgbaColors.add(data.readUInt32BE(index));
}
const total = width * height;
return { rgbaColorCount: rgbaColors.size, distinctAlphaCount: alphas.size, transparentPixels: empty, partiallyTransparentPixels: partial, opaquePixels: opaque, transparentPercent: percent(empty, total), partiallyTransparentPercent: percent(partial, total), opaquePercent: percent(opaque, total), hasActualTransparency: empty + partial > 0 };
}
function countTop(counts) { let index = 0; for (let i = 1; i < counts.length; i++) if (counts[i] > counts[index]) index = i; return index; }
const strictThresholds = Object.freeze({ edgeFraction: 0.22, rejectionDominantMinimum: 92, rejectionClusterMinimum: 6, rejectionEdgeCoreDelta: 12, rejectionDeltaClusterMinimum: 3, reviewDominantMinimum: 96, reviewClusterMinimum: 3, reviewEdgeCoreDelta: 8, reviewDeltaClusterMinimum: 1.5, possibleSubjectClusterMinimum: 2, minimumClusterPixels: 6 });
function cropImage(source, region) {
const target = new PNG({ width: region.width, height: region.height });
PNG.bitblt(source, target, region.x, region.y, region.width, region.height, 0, 0);
return target;
}
function strictLineBand(axis, index) {
const line = axis.lines[index];
if (!axis.likely || line.visibleStart === null || line.looseCoveragePercent < 55) return null;
// A single source pixel beyond the measured loose guide excludes the remaining guide antialias fringe.
return { start: line.visibleStart - 1, end: line.visibleEnd + 1 };
}
function componentsFor(labels, width, height, color, total) {
const visited = new Uint8Array(labels.length); const components = [];
for (let offset = 0; offset < labels.length; offset++) {
if (visited[offset] || labels[offset] !== color) continue;
const stack = [offset]; visited[offset] = 1;
let count = 0; let minimumX = width; let minimumY = height; let maximumX = -1; let maximumY = -1;
while (stack.length) {
const current = stack.pop(); const x = current % width; const y = Math.floor(current / width); count++;
minimumX = Math.min(minimumX, x); minimumY = Math.min(minimumY, y); maximumX = Math.max(maximumX, x); maximumY = Math.max(maximumY, y);
for (const adjacent of [x > 0 ? current - 1 : -1, x < width - 1 ? current + 1 : -1, y > 0 ? current - width : -1, y < height - 1 ? current + width : -1]) {
if (adjacent < 0 || visited[adjacent] || labels[adjacent] !== color) continue;
visited[adjacent] = 1; stack.push(adjacent);
}
}
components.push({ pixels: count, areaPercent: percent(count, total), localBounds: { x: minimumX, y: minimumY, width: maximumX - minimumX + 1, height: maximumY - minimumY + 1 }, touchesUsableEdge: minimumX === 0 || minimumY === 0 || maximumX === width - 1 || maximumY === height - 1 });
}
return components.sort((a, b) => b.pixels - a.pixels);
}
function strictCell(png, x, y, horizontal, vertical, palette, options) {
const left = strictLineBand(horizontal, x); const right = strictLineBand(horizontal, x + 1); const top = strictLineBand(vertical, y); const bottom = strictLineBand(vertical, y + 1);
if (!left || !right || !top || !bottom) return { x, y, acquisition: 'unavailable', reason: 'One or more enclosing guide bands were not acquired with at least 55% line coverage.' };
const startX = clamp(left.end + 1, 0, png.width - 1); const endX = clamp(right.start - 1, 0, png.width - 1);
const startY = clamp(top.end + 1, 0, png.height - 1); const endY = clamp(bottom.start - 1, 0, png.height - 1);
const width = endX - startX + 1; const height = endY - startY + 1;
if (width < 4 || height < 4) return { x, y, acquisition: 'unavailable', reason: 'The enclosing measured guide bands leave fewer than four usable source pixels.' };
const total = width * height; const edgeX = Math.max(1, Math.round(width * strictThresholds.edgeFraction)); const edgeY = Math.max(1, Math.round(height * strictThresholds.edgeFraction));
const full = palette.map(() => 0); const core = full.slice(); const edge = full.slice(); const labels = new Uint8Array(total);
let corePixels = 0; let edgePixels = 0; let offPalette = 0; let distanceTotal = 0; let alphaEmpty = 0; let alphaPartial = 0;
for (let ry = 0; ry < height; ry++) for (let rx = 0; rx < width; rx++) {
const source = ((ry + startY) * png.width + rx + startX) * 4;
const rgba = [png.data[source], png.data[source + 1], png.data[source + 2], png.data[source + 3]];
if (rgba[3] === 0) alphaEmpty++; else if (rgba[3] < 255) alphaPartial++;
const mapped = rgba[3] < 32 && options.transparentIndex !== -1 ? { index: options.transparentIndex, distance: 0 } : nearestColor(rgba, palette);
labels[ry * width + rx] = mapped.index; full[mapped.index]++; distanceTotal += mapped.distance;
if (mapped.distance > options.tolerance) offPalette++;
if (rx < edgeX || rx >= width - edgeX || ry < edgeY || ry >= height - edgeY) { edge[mapped.index]++; edgePixels++; } else { core[mapped.index]++; corePixels++; }
}
const dominant = countTop(full); const coreDominant = countTop(core); const edgeDominant = countTop(edge);
const shares = full.map((count, color) => ({ index: color, name: palette[color].name, fullPercent: percent(count, total), corePercent: percent(core[color], corePixels), edgePercent: percent(edge[color], edgePixels) })).sort((a, b) => b.fullPercent - a.fullPercent);
const clusters = full.flatMap((count, color) => !count ? [] : componentsFor(labels, width, height, color, total).filter(component => component.pixels >= strictThresholds.minimumClusterPixels).map(component => ({ index: color, name: palette[color].name, ...component, sourceBounds: { x: startX + component.localBounds.x, y: startY + component.localBounds.y, width: component.localBounds.width, height: component.localBounds.height } }))).sort((a, b) => b.pixels - a.pixels);
const minorities = clusters.filter(cluster => cluster.index !== dominant);
const largestMinority = minorities[0] ?? null;
const dominantShare = percent(full[dominant], total);
const edgeCoreDelta = rounded(Math.abs(percent(core[dominant], corePixels) - percent(edge[dominant], edgePixels)));
const reasons = [];
const largestShare = largestMinority?.areaPercent ?? 0;
if (dominantShare < strictThresholds.rejectionDominantMinimum) reasons.push('usable-interior-dominant-below-92%');
if (largestShare >= strictThresholds.rejectionClusterMinimum) reasons.push('contiguous-minority-at-least-6%');
if (edgeCoreDelta >= strictThresholds.rejectionEdgeCoreDelta && largestShare >= strictThresholds.rejectionDeltaClusterMinimum) reasons.push('edge/core-difference-at-least-12pp-with-3%-cluster');
if (coreDominant !== edgeDominant && largestShare >= strictThresholds.rejectionDeltaClusterMinimum) reasons.push('edge-and-core-disagree-with-3%-cluster');
const reviewReasons = [];
if (dominantShare < strictThresholds.reviewDominantMinimum) reviewReasons.push('usable-interior-dominant-below-96%');
if (largestShare >= strictThresholds.reviewClusterMinimum) reviewReasons.push('contiguous-minority-at-least-3%');
if (edgeCoreDelta >= strictThresholds.reviewEdgeCoreDelta && largestShare >= strictThresholds.reviewDeltaClusterMinimum) reviewReasons.push('edge/core-difference-at-least-8pp-with-1.5%-cluster');
if (coreDominant !== edgeDominant) reviewReasons.push('edge-and-core-disagree');
const foregroundClusters = clusters.filter(cluster => cluster.index !== options.transparentIndex);
const transparentClusters = clusters.filter(cluster => cluster.index === options.transparentIndex);
return { x, y, acquisition: 'acquired', usableBounds: { x: startX, y: startY, width, height }, usableFractionOfNominalCellPercent: percent(total, horizontal.pitch * vertical.pitch), samplePixels: total, corePixels, edgePixels, dominant: palette[dominant].name, dominantIndex: dominant, purityPercent: dominantShare, coreDominant: palette[coreDominant].name, edgeDominant: palette[edgeDominant].name, dominantCorePercent: percent(core[dominant], corePixels), dominantEdgePercent: percent(edge[dominant], edgePixels), absoluteEdgeCoreDifferencePercentagePoints: edgeCoreDelta, nearestPaletteShares: shares, largestMinority, minorityClusters: minorities.filter(cluster => cluster.areaPercent >= 1), status: reasons.length ? 'reject' : reviewReasons.length ? 'review' : 'pass', reasons: reasons.length ? reasons : reviewReasons, source: { meanPaletteResidual: rounded(distanceTotal / total), offPalettePercent: percent(offPalette, total), alphaEmptyPercent: percent(alphaEmpty, total), alphaPartialPercent: percent(alphaPartial, total) }, isDominantSubject: options.transparentIndex < 0 || dominant !== options.transparentIndex, isPossibleSubject: options.transparentIndex < 0 || dominant !== options.transparentIndex || foregroundClusters.some(cluster => cluster.areaPercent >= strictThresholds.possibleSubjectClusterMinimum), hasMeaningfulKey: options.transparentIndex >= 0 && (dominant === options.transparentIndex || transparentClusters.some(cluster => cluster.areaPercent >= strictThresholds.possibleSubjectClusterMinimum)) };
}
function strictSummary(cells, size, palette, options, geometryAcquired) {
const acquired = cells.filter(cell => cell.acquisition === 'acquired');
for (const cell of acquired) {
const adjacent = [[cell.x - 1, cell.y], [cell.x + 1, cell.y], [cell.x, cell.y - 1], [cell.x, cell.y + 1]].map(([x, y]) => x < 0 || x >= size || y < 0 || y >= size ? null : cells[y * size + x]).filter(candidate => candidate?.acquisition === 'acquired');
cell.isSubjectBoundary = options.transparentIndex < 0 ? adjacent.some(other => other.dominantIndex !== cell.dominantIndex) : (cell.isPossibleSubject && (cell.hasMeaningfulKey || adjacent.some(other => !other.isDominantSubject))) || (!cell.isDominantSubject && adjacent.some(other => other.isDominantSubject));
cell.classification = options.transparentIndex < 0 ? 'opaque-design-cell' : cell.isDominantSubject ? 'occupied-dominant' : cell.isPossibleSubject ? 'empty-dominant-with-subject-spill' : 'empty';
}
const summary = subset => { const lowest = subset.length ? Math.min(...subset.map(cell => cell.purityPercent)) : null; return { cells: subset.length, rejected: subset.filter(cell => cell.status === 'reject').length, review: subset.filter(cell => cell.status === 'review').length, pass: subset.filter(cell => cell.status === 'pass').length, meanPurityPercent: subset.length ? rounded(mean(subset.map(cell => cell.purityPercent))) : null, lowestPurityPercent: lowest, maximumEdgeCoreDifferencePercentagePoints: subset.length ? Math.max(...subset.map(cell => cell.absoluteEdgeCoreDifferencePercentagePoints)) : null }; };
const groups = { all: summary(acquired), occupied: summary(acquired.filter(cell => cell.isDominantSubject)), subjectOrSpill: summary(acquired.filter(cell => cell.isPossibleSubject)), subjectBoundary: summary(acquired.filter(cell => cell.isSubjectBoundary)), empty: summary(acquired.filter(cell => !cell.isDominantSubject)), emptyFarFromSubject: summary(acquired.filter(cell => !cell.isPossibleSubject && !cell.isSubjectBoundary)) };
const asDiagnostic = cell => ({ x: cell.x, y: cell.y, classification: cell.classification, subjectBoundary: cell.isSubjectBoundary, status: cell.status, dominant: cell.dominant, purityPercent: cell.purityPercent, core: { dominant: cell.coreDominant, sharePercent: cell.dominantCorePercent }, edge: { dominant: cell.edgeDominant, sharePercent: cell.dominantEdgePercent }, edgeCoreDifferencePercentagePoints: cell.absoluteEdgeCoreDifferencePercentagePoints, largestMinority: cell.largestMinority, reasons: cell.reasons });
const rejected = acquired.filter(cell => cell.status === 'reject'); const review = acquired.filter(cell => cell.status === 'review');
return { status: !geometryAcquired || acquired.length !== cells.length ? 'unscored-geometry' : rejected.length ? 'reject-grid-fidelity' : review.length ? 'review-grid-fidelity' : 'pass-grid-fidelity', geometryAcquired, acquiredCells: acquired.length, unscoredCells: cells.filter(cell => cell.acquisition !== 'acquired'), thresholds: strictThresholds, artifactAcceptance: 'These are empirical triage thresholds, not permission for intentional subdivisions. A candidate requires every guide/cell acquired, zero rejected cells, zero review cells, a source palette/alpha contract check, and human visual review of the source and native result. Detectable rejection remains actionable even if other cells were unscored.', coordinateConvention: 'zero-based native cell coordinates x,y; source bounds are crop-relative when a crop was supplied', protocol: 'Find enclosing high-coverage loose guide bands on both axes; exclude the measured guide extent and one fringe source pixel; classify every remaining source pixel to its nearest explicit palette color before majority selection. Edge is the outer 22% of the usable interior in either axis, core is the rest. Minority components are four-connected and normalized over the whole usable cell; groups are reported separately. For an opaque asset, a boundary is a four-neighbor palette boundary.', groups, rejectedCoordinates: rejected.map(asDiagnostic), reviewCoordinates: review.map(asDiagnostic), paletteCountsFromStrictMajority: Object.fromEntries(palette.map((color, index) => [color.name, acquired.filter(cell => cell.dominantIndex === index).length])), rows: Array.from({ length: size }, (_, y) => cells.filter(cell => cell.y === y)) };
}
function strictOverlay(source, cells, horizontal, vertical) {
const out = new PNG({ width: source.width, height: source.height });
for (let i = 0; i < source.data.length; i += 4) { const alpha = source.data[i + 3] / 255; for (let c = 0; c < 3; c++) out.data[i + c] = Math.round((source.data[i + c] * alpha + 255 * (1 - alpha)) * 0.60 + 255 * 0.40); out.data[i + 3] = 255; }
const pixel = (x, y, color) => { x = Math.round(x); y = Math.round(y); if (x < 0 || x >= out.width || y < 0 || y >= out.height) return; const i = (y * out.width + x) * 4; for (let c = 0; c < 3; c++) out.data[i + c] = color[c]; };
const rectangle = (x, y, width, height, color, thickness = 2) => { for (let inset = 0; inset < thickness; inset++) { for (let px = x + inset; px <= x + width - 1 - inset; px++) { pixel(px, y + inset, color); pixel(px, y + height - 1 - inset, color); } for (let py = y + inset; py <= y + height - 1 - inset; py++) { pixel(x + inset, py, color); pixel(x + width - 1 - inset, py, color); } } };
for (const cell of cells) {
if (cell.acquisition !== 'acquired') { const x = Math.round(horizontal.origin + (cell.x + 0.5) * horizontal.pitch); const y = Math.round(vertical.origin + (cell.y + 0.5) * vertical.pitch); for (let offset = -5; offset <= 5; offset++) { pixel(x + offset, y + offset, [40, 80, 255]); pixel(x + offset, y - offset, [40, 80, 255]); } continue; }
if (cell.status === 'pass') continue;
const color = cell.status === 'reject' ? [235, 27, 48] : [240, 137, 0]; const b = cell.usableBounds;
rectangle(b.x, b.y, b.width, b.height, color, Math.max(2, Math.round(Math.min(b.width, b.height) / 22)));
if (cell.largestMinority) { const b = cell.largestMinority.sourceBounds; for (let py = b.y; py < b.y + b.height; py += 3) pixel(b.x, py, [0, 130, 220]); for (let px = b.x; px < b.x + b.width; px += 3) pixel(px, b.y, [0, 130, 220]); }
}
return out;
}
function sampleCell(png, x, y, geometry, palette, options) {
const { width, height, data } = png;
const left = geometry.x + geometry.pitchX * x; const top = geometry.y + geometry.pitchY * y;
const margin = options.margin;
const startX = clamp(Math.ceil(left + margin * geometry.pitchX), 0, width - 1); const endX = clamp(Math.floor(left + (1 - margin) * geometry.pitchX), 0, width - 1);
const startY = clamp(Math.ceil(top + margin * geometry.pitchY), 0, height - 1); const endY = clamp(Math.floor(top + (1 - margin) * geometry.pitchY), 0, height - 1);
const centerX = left + 0.5 * geometry.pitchX; const centerY = top + 0.5 * geometry.pitchY;
const counts = new Array(palette.length).fill(0); const centerCounts = counts.slice(); const exact = new Map();
let sampled = 0; let centers = 0; let residual = 0; let offPalette = 0; let likelyTransition = 0; let guideInterior = 0; let boundaries = 0; let boundaryTransition = 0; let boundaryGuide = 0; let ignoredAlpha = 0;
const transitionColors = [...palette.map(color => color.rgb), options.guide === 'magenta' ? [255, 0, 255] : [0, 0, 0]];
function position(px, py) {
const index = (py * width + px) * 4; const rgba = [data[index], data[index + 1], data[index + 2], data[index + 3]];
if (rgba[3] < 32 && options.transparentIndex !== -1) return { rgba, mapped: { index: options.transparentIndex, distance: 0 }, guideLike: false, transition: false };
const mapped = nearestColor(rgba, palette);
const guideLike = guideMatch(...rgba, options.guide, true);
const transition = mapped.distance > 20 && transitionColor(rgba, transitionColors);
return { rgba, mapped, guideLike, transition };
}
for (let py = startY; py <= endY; py++) for (let px = startX; px <= endX; px++) {
const { rgba, mapped, guideLike, transition } = position(px, py);
sampled++; counts[mapped.index]++; residual += mapped.distance;
if (mapped.distance > options.tolerance) offPalette++;
if (transition) likelyTransition++;
if (guideLike) guideInterior++;
if (rgba[3] < 32) ignoredAlpha++;
const key = rgba.join(','); exact.set(key, (exact.get(key) ?? 0) + 1);
if (Math.abs(px - centerX) <= geometry.pitchX * 0.12 && Math.abs(py - centerY) <= geometry.pitchY * 0.12) { centers++; centerCounts[mapped.index]++; }
}
const ringMinX = clamp(Math.ceil(left + 0.08 * geometry.pitchX), 0, width - 1); const ringMaxX = clamp(Math.floor(left + 0.92 * geometry.pitchX), 0, width - 1);
const ringMinY = clamp(Math.ceil(top + 0.08 * geometry.pitchY), 0, height - 1); const ringMaxY = clamp(Math.floor(top + 0.92 * geometry.pitchY), 0, height - 1);
for (let py = ringMinY; py <= ringMaxY; py++) for (let px = ringMinX; px <= ringMaxX; px++) {
if (px >= startX && px <= endX && py >= startY && py <= endY) continue;
const { guideLike, transition } = position(px, py); boundaries++;
if (guideLike) boundaryGuide++; if (transition) boundaryTransition++;
}
const majority = countTop(counts); const center = countTop(centerCounts);
const centerPixel = position(clamp(Math.round(centerX), 0, width - 1), clamp(Math.round(centerY), 0, height - 1));
let exactTop = ['', 0]; for (const pair of exact) if (pair[1] > exactTop[1]) exactTop = pair;
const representative = exactTop[0] ? exactTop[0].split(',').map(Number) : centerPixel.rgba;
const sortedPalette = counts.map((count, index) => ({ name: palette[index].name, index, sharePercent: percent(count, sampled) })).sort((a, b) => b.sharePercent - a.sharePercent);
return { x, y, samplePixels: sampled, majority: palette[majority].name, majorityIndex: majority, dominantPalettePercent: percent(counts[majority], sampled), center: palette[center].name, centerIndex: center, centerDominantPalettePercent: percent(centerCounts[center], centers), centerPixel: { rgba: centerPixel.rgba, nearest: palette[centerPixel.mapped.index].name, residual: rounded(centerPixel.mapped.distance) }, representativeRgba: representative, dominantExactRgbaPercent: percent(exactTop[1], sampled), meanPaletteResidual: rounded(residual / Math.max(1, sampled)), offPalettePercent: percent(offPalette, sampled), likelyTransitionInteriorPercent: percent(likelyTransition, sampled), guideLikeInteriorPercent: percent(guideInterior, sampled), transitionBoundaryRingPercent: percent(boundaryTransition, boundaries), guideLikeBoundaryRingPercent: percent(boundaryGuide, boundaries), transparentSourceInteriorPercent: percent(ignoredAlpha, sampled), nearestPaletteShares: sortedPalette };
}
function makeNative(cells, size, palette, field, transparentIndex) {
const png = new PNG({ width: size, height: size });
for (const cell of cells) {
const rgba = field === 'representativeRgba' ? cell.representativeRgba : [...palette[cell[field]].rgb, cell[field] === transparentIndex ? 0 : 255];
const index = (cell.y * size + cell.x) * 4; rgba.forEach((value, channel) => { png.data[index + channel] = value; });
}
return png;
}
function zoom(source, scale = 16) {
const png = new PNG({ width: source.width * scale, height: source.height * scale });
for (let y = 0; y < png.height; y++) for (let x = 0; x < png.width; x++) {
const sourceIndex = (Math.floor(y / scale) * source.width + Math.floor(x / scale)) * 4;
const targetIndex = (y * png.width + x) * 4; const a = source.data[sourceIndex + 3] / 255;
const checker = ((Math.floor(x / 8) + Math.floor(y / 8)) % 2) ? 227 : 247;
for (let channel = 0; channel < 3; channel++) png.data[targetIndex + channel] = Math.round(a * source.data[sourceIndex + channel] + (1 - a) * checker);
png.data[targetIndex + 3] = 255;
}
return png;
}
export async function evaluate(input, rawOptions = {}) {
const phaseSearch = searchBound(rawOptions['phase-search'], 'phase-search');
const pitchSearch = searchBound(rawOptions['pitch-search'], 'pitch-search');
loadPng();
const original = PNG.sync.read(await readFile(input));
const rawCrop = rawOptions.crop?.split(',').map(Number);
if (rawCrop && (rawCrop.length !== 4 || rawCrop.some(value => !Number.isInteger(value)) || rawCrop[0] < 0 || rawCrop[1] < 0 || rawCrop[2] < 8 || rawCrop[3] < 8 || rawCrop[0] + rawCrop[2] > original.width || rawCrop[1] + rawCrop[3] > original.height)) throw new Error('--crop requires in-bounds integer x,y,width,height as original-image pixel values');
const crop = rawCrop ? { x: rawCrop[0], y: rawCrop[1], width: rawCrop[2], height: rawCrop[3] } : null;
const png = crop ? cropImage(original, crop) : original;
for (const [name, value] of [['phase-search', phaseSearch], ['pitch-search', pitchSearch]]) {
if (value !== undefined && value > Math.max(png.width, png.height)) {
throw new Error(`--${name} must not exceed the largest source-image dimension`);
}
}
const size = Number(rawOptions.cells ?? 16); if (!Number.isInteger(size) || size < 2 || size > 64) throw new Error('--cells must be an integer from 2 to 64');
const guide = rawOptions.guide ?? 'black'; if (!['black', 'magenta'].includes(guide)) throw new Error('--guide must be black or magenta');
const margin = Number(rawOptions.margin ?? 0.19); if (!(margin >= 0.10 && margin < 0.39)) throw new Error('--margin must be from 0.10 to less than 0.39');
const palette = (rawOptions.palette ? rawOptions.palette.split(',') : defaults).map(parseColor);
if (palette.length < 2 || palette.length > 12) throw new Error('Use from two to twelve palette colors');
const transparency = rawOptions.transparent ?? 'transparent';
const transparentIndex = transparency === 'none' ? -1 : /^\d+$/.test(transparency) ? Number(transparency) : palette.findIndex(color => color.name === transparency);
if (transparency !== 'none' && (transparentIndex < 0 || transparentIndex >= palette.length)) throw new Error('The --transparent palette name or index does not exist; use none for an opaque target');
const options = { guide, margin, tolerance: Number(rawOptions.tolerance ?? 42), transparentIndex, phaseSearch, pitchSearch };
const grid = rawOptions.grid?.split(',').map(Number);
if (grid && (grid.length !== 4 || grid.some(value => !Number.isFinite(value)) || grid[2] < 2 || grid[3] < 2)) throw new Error('--grid requires x,y,pitchX,pitchY as source-image pixel values');
const expected = grid ? { x: grid[0], y: grid[1], pitchX: grid[2], pitchY: grid[3] } : { x: 31 * png.width / 1024, y: 31 * png.height / 1024, pitchX: 60 * png.width / 1024, pitchY: 60 * png.height / 1024 };
const coverage = profiles(png, expected, guide, size);
const horizontal = detectAxis(coverage.columns, expected.x, expected.pitchX, size, png.width / 1024, options);
const vertical = detectAxis(coverage.rows, expected.y, expected.pitchY, size, png.height / 1024, options);
const geometry = { x: horizontal.origin, y: vertical.origin, pitchX: horizontal.pitch, pitchY: vertical.pitch };
const cells = [];
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) cells.push(sampleCell(png, x, y, geometry, palette, options));
const fullCells = [];
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) fullCells.push(strictCell(png, x, y, horizontal, vertical, palette, options));
const strict = strictSummary(fullCells, size, palette, options, horizontal.likely && vertical.likely);
const rollup = property => rounded(mean(cells.map(cell => cell[property])));
const disagreements = cells.filter(cell => cell.majorityIndex !== cell.centerIndex).map(cell => ({ x: cell.x, y: cell.y, majority: cell.majority, center: cell.center }));
const low = cells.filter(cell => cell.dominantPalettePercent < 80).map(cell => ({ x: cell.x, y: cell.y, majority: cell.majority, dominantPalettePercent: cell.dominantPalettePercent }));
const highTransitions = cells.filter(cell => cell.likelyTransitionInteriorPercent > 10).map(cell => ({ x: cell.x, y: cell.y, percent: cell.likelyTransitionInteriorPercent }));
const analysis = {
source: { path: resolve(input), width: png.width, height: png.height, aspectRatio: rounded(png.width / png.height, 4), expectedSquare: png.width === png.height, originalDimensions: { width: original.width, height: original.height }, crop, alphaAndColors: scanAlpha(png) },
protocol: { size, guide, expectedGrid: expected, effectiveGrid: geometry, interiorFractionFromEachSide: margin, excludedSourceBorderBeforeBoundaryRingFraction: 0.08, referencePalette: palette.map(({ name, hex }) => ({ name, hex })), transparentTargetIndex: transparentIndex === -1 ? null : transparentIndex, offPaletteEuclideanRgbThreshold: options.tolerance, guideStrict: guide === 'black' ? 'alpha >= 200; max channel <= 26; channel spread <= 15' : 'alpha >= 200; R >= 215; B >= 200; G <= 70', guideLoose: guide === 'black' ? 'alpha >= 200; max channel <= 52; channel spread <= 18' : 'alpha >= 200; R >= 155; B >= 150; G <= 130; min(R,B)-G >= 55' },
guides: { likelyDetectedBothAxes: horizontal.likely && vertical.likely, columns: horizontal, rows: vertical, coordinateStrategy: 'globally periodic best-fit center lines independently per axis when detected, expected bounds otherwise; no perspective correction' },
cells: { total: cells.length, summary: { meanDominantPalettePercent: rollup('dominantPalettePercent'), meanDominantExactRgbaPercent: rollup('dominantExactRgbaPercent'), meanPaletteResidual: rollup('meanPaletteResidual'), meanOffPalettePercent: rollup('offPalettePercent'), meanLikelyTransitionInteriorPercent: rollup('likelyTransitionInteriorPercent'), meanGuideLikeInteriorPercent: rollup('guideLikeInteriorPercent'), meanTransitionBoundaryRingPercent: rollup('transitionBoundaryRingPercent'), meanGuideLikeBoundaryRingPercent: rollup('guideLikeBoundaryRingPercent'), exactMajorityCenterDisagreements: disagreements.length, nonflatBelow80Percent: low.length, cellsWithTransitionSpillOver10Percent: highTransitions.length, paletteCountsFromMajority: Object.fromEntries(palette.map((color, index) => [color.name, cells.filter(cell => cell.majorityIndex === index).length])) }, centerMajorityDisagreements: disagreements, nonflatCellsBelow80Percent: low, highTransitionSpillCells: highTransitions, rows: Array.from({ length: size }, (_, y) => cells.filter(cell => cell.y === y)) },
strict,
limits: ['Palette metrics depend on supplied reference colors; genuinely different flat fills can have large palette residuals without anti-aliasing. Multiple visually distinct source colors mapped into the same supplied palette class can conceal a split; source residual remains separate.', 'The transition metric is a color-mixing heuristic and can mistake palette-adjacent intentional shades or compression; it is not an independent proof of anti-aliasing.', 'Near-black pure art can contribute to guide-like interior metrics; full-length axis coverage is a stronger indicator of a guide.', 'Perspective, nonlinear warp, or heavily hand-drawn grid distortion is not corrected. Both axes, per-line displacement, and native previews must be inspected. Strict classification refuses to score geometry when the guide is not acquired; partial acquisition never produces a pass.', 'Source alpha is measured independently; white/pale target transparency exists only when explicitly assigned to a palette slot; majority/center images show that mapping.', 'Artwork readability, silhouette, anchors, seams, and animation quality are qualitative and require looking at native samples.'],
};
const strictNativeCells = fullCells.filter(cell => cell.acquisition === 'acquired');
return { analysis, images: { majority: makeNative(cells, size, palette, 'majorityIndex', transparentIndex), center: makeNative(cells, size, palette, 'centerIndex', transparentIndex), 'source-modal': makeNative(cells, size, palette, 'representativeRgba', -1), 'strict-majority': makeNative(strictNativeCells, size, palette, 'dominantIndex', transparentIndex), 'strict-overlay': strictOverlay(png, fullCells, horizontal, vertical) } };
}
async function main() {
const options = cli(process.argv.slice(2));
if (options.help || !options.inputs.length) {
console.log('node skills/modretro-chromatic-pixel-art/scripts/evaluate-grid.mjs <image.png> [more.png] [--out <directory>] [--name <single-output-stem>] [--palette transparent=#ffffff,ink=#22303a,light=#8ebe67,mid=#4d8751] [--transparent <name|index|none>] [--grid <x,y,pitchX,pitchY>] [--crop <x,y,width,height>] [--guide black|magenta] [--cells 16] [--tolerance 42] [--margin 0.19] [--phase-search <source-pixels>] [--pitch-search <source-pixels>]');
console.log('Defaults target a 1024 square template with 16 cells and line centers 31+60*n, scaled with input dimensions. Writes a full JSON, native palette-majority/center/raw-modal/strict-majority PNGs, checkerboard previews, and source-size strict diagnostic overlay (red rejects, amber reviews, blue partial cluster or unscored cross).');
console.log('For a lean plugin payload, set GB_STUDIO_RUNTIME_ROOT to its configured prepared runtime so the helper can resolve pngjs. Help works before that dependency is loaded.');
console.log(`Search bounds must be finite, nonnegative, no larger than the largest source-image dimension, and within ${maximumSearchCandidates} candidates per axis. Output paths must be unique and must not already exist.`);
return;
}
if (options.name && options.inputs.length !== 1) throw new Error('--name supports exactly one input');
const directory = resolve(options.out ?? '.local/imagegen-workflow/grid-evaluations');
const imageNames = ['majority', 'center', 'source-modal', 'strict-majority', 'strict-overlay'];
const plans = options.inputs.map(input => {
const stem = options.name ?? parsePath(input).name;
if (!/^[\w.-]+$/.test(stem)) throw new Error('--name must be a file stem containing only letters, digits, period, underscore, or dash');
const filenames = [`${stem}.json`, ...imageNames.flatMap(name => name === 'strict-overlay'
? [`${stem}-${name}.png`]
: [`${stem}-${name}.png`, `${stem}-${name}-zoom.png`])];
return { input, stem, outputs: filenames.map(filename => resolve(directory, filename)) };
});
const destinations = new Set();
for (const output of plans.flatMap(plan => plan.outputs)) {
const portableKey = output.toLowerCase();
if (destinations.has(portableKey)) throw new Error(`Colliding output path: ${output}. Use distinct input basenames or separate output directories.`);
destinations.add(portableKey);
try {
await lstat(output);
} catch (error) {
if (error.code === 'ENOENT') continue;
throw error;
}
throw new Error(`Output already exists: ${output}. Choose a new output directory or --name.`);
}
for (const { input, stem } of plans) {
const { analysis, images } = await evaluate(input, options);
await mkdir(directory, { recursive: true });
await writeFile(resolve(directory, `${stem}.json`), JSON.stringify(analysis, null, 2) + '\n', { flag: 'wx' });
for (const [name, png] of Object.entries(images)) {
await writeFile(resolve(directory, `${stem}-${name}.png`), PNG.sync.write(png), { flag: 'wx' });
if (name !== 'strict-overlay') await writeFile(resolve(directory, `${stem}-${name}-zoom.png`), PNG.sync.write(zoom(png)), { flag: 'wx' });
}
console.log(JSON.stringify({ input, output: resolve(directory, `${stem}.json`), source: analysis.source, guides: { both: analysis.guides.likelyDetectedBothAxes, columns: { strong: analysis.guides.columns.strongLines, pitch: analysis.guides.columns.pitch }, rows: { strong: analysis.guides.rows.strongLines, pitch: analysis.guides.rows.pitch } }, metrics: analysis.cells.summary, strict: { status: analysis.strict.status, groups: analysis.strict.groups, rejected: analysis.strict.rejectedCoordinates.map(({ x, y }) => [x, y]), review: analysis.strict.reviewCoordinates.map(({ x, y }) => [x, y]) } }));
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) main().catch(error => { console.error(error.message); process.exitCode = 1; });
SHA-256: 5a87f14c4676f3b91fc3dea738211939a835f179023508e2b48b898d5611f998