479 lines
13 KiB
TypeScript
479 lines
13 KiB
TypeScript
export type MutableRgbaBuffer = Uint8Array | Uint8ClampedArray;
|
|
|
|
const SOFT_EDGE_ALPHA_THRESHOLD = 224;
|
|
const FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD = 96;
|
|
|
|
function clamp01(value: number) {
|
|
return Math.max(0, Math.min(1, value));
|
|
}
|
|
|
|
function lerp(from: number, to: number, t: number) {
|
|
return from + (to - from) * clamp01(t);
|
|
}
|
|
|
|
function computeGreenBackgroundScore(
|
|
red: number,
|
|
green: number,
|
|
blue: number,
|
|
alpha: number,
|
|
) {
|
|
if (alpha === 0) {
|
|
return 1;
|
|
}
|
|
|
|
const greenLead = green - Math.max(red, blue);
|
|
if (green < 52 || greenLead <= 8) {
|
|
return 0;
|
|
}
|
|
|
|
const greenRatio = green / Math.max(1, red + blue);
|
|
if (greenRatio <= 0.52) {
|
|
return 0;
|
|
}
|
|
|
|
return clamp01(
|
|
((green - 52) / 168) * 0.22 +
|
|
((greenLead - 8) / 96) * 0.53 +
|
|
((greenRatio - 0.52) / 0.82) * 0.25,
|
|
);
|
|
}
|
|
|
|
function computeWhiteBackgroundScore(
|
|
red: number,
|
|
green: number,
|
|
blue: number,
|
|
alpha: number,
|
|
) {
|
|
if (alpha === 0) {
|
|
return 1;
|
|
}
|
|
|
|
const maxChannel = Math.max(red, green, blue);
|
|
const minChannel = Math.min(red, green, blue);
|
|
const average = (red + green + blue) / 3;
|
|
if (average < 188 || minChannel < 168) {
|
|
return 0;
|
|
}
|
|
|
|
const spread = maxChannel - minChannel;
|
|
const neutrality = 1 - clamp01((spread - 6) / 34);
|
|
const brightness = clamp01((average - 188) / 55);
|
|
const floor = clamp01((minChannel - 168) / 60);
|
|
return clamp01(neutrality * (brightness * 0.85 + floor * 0.15));
|
|
}
|
|
|
|
function collectForegroundNeighborColor(
|
|
pixels: MutableRgbaBuffer,
|
|
width: number,
|
|
height: number,
|
|
x: number,
|
|
y: number,
|
|
backgroundMask: Uint8Array,
|
|
backgroundHints: Float32Array,
|
|
) {
|
|
let totalWeight = 0;
|
|
let totalRed = 0;
|
|
let totalGreen = 0;
|
|
let totalBlue = 0;
|
|
|
|
for (let offsetY = -2; offsetY <= 2; offsetY += 1) {
|
|
for (let offsetX = -2; offsetX <= 2; offsetX += 1) {
|
|
if (offsetX === 0 && offsetY === 0) {
|
|
continue;
|
|
}
|
|
|
|
const nextX = x + offsetX;
|
|
const nextY = y + offsetY;
|
|
if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) {
|
|
continue;
|
|
}
|
|
|
|
const nextPixelIndex = nextY * width + nextX;
|
|
if (backgroundMask[nextPixelIndex]) {
|
|
continue;
|
|
}
|
|
|
|
if ((backgroundHints[nextPixelIndex] ?? 0) >= 0.18) {
|
|
continue;
|
|
}
|
|
|
|
const nextOffset = nextPixelIndex * 4;
|
|
const nextAlpha = pixels[nextOffset + 3] ?? 0;
|
|
if (nextAlpha < FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD) {
|
|
continue;
|
|
}
|
|
|
|
const distance = Math.abs(offsetX) + Math.abs(offsetY);
|
|
const weight =
|
|
(nextAlpha / 255) *
|
|
(distance <= 1 ? 1.8 : distance === 2 ? 1.2 : 0.7);
|
|
|
|
totalWeight += weight;
|
|
totalRed += (pixels[nextOffset] ?? 0) * weight;
|
|
totalGreen += (pixels[nextOffset + 1] ?? 0) * weight;
|
|
totalBlue += (pixels[nextOffset + 2] ?? 0) * weight;
|
|
}
|
|
}
|
|
|
|
if (totalWeight <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
red: Math.round(totalRed / totalWeight),
|
|
green: Math.round(totalGreen / totalWeight),
|
|
blue: Math.round(totalBlue / totalWeight),
|
|
};
|
|
}
|
|
|
|
export function removeBackgroundFromRgba(
|
|
pixels: MutableRgbaBuffer,
|
|
width: number,
|
|
height: number,
|
|
) {
|
|
const pixelCount = width * height;
|
|
if (pixelCount <= 0) {
|
|
return false;
|
|
}
|
|
|
|
const backgroundMask = new Uint8Array(pixelCount);
|
|
const greenScores = new Float32Array(pixelCount);
|
|
const whiteScores = new Float32Array(pixelCount);
|
|
const backgroundHints = new Float32Array(pixelCount);
|
|
const queue: number[] = [];
|
|
let queueIndex = 0;
|
|
let changed = false;
|
|
|
|
for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) {
|
|
const offset = pixelIndex * 4;
|
|
const red = pixels[offset] ?? 0;
|
|
const green = pixels[offset + 1] ?? 0;
|
|
const blue = pixels[offset + 2] ?? 0;
|
|
const alpha = pixels[offset + 3] ?? 0;
|
|
const greenScore = computeGreenBackgroundScore(red, green, blue, alpha);
|
|
const whiteScore = computeWhiteBackgroundScore(red, green, blue, alpha);
|
|
const transparencyHint = clamp01((56 - alpha) / 56) * 0.75;
|
|
|
|
greenScores[pixelIndex] = greenScore;
|
|
whiteScores[pixelIndex] = whiteScore;
|
|
backgroundHints[pixelIndex] = Math.max(
|
|
greenScore,
|
|
whiteScore,
|
|
transparencyHint,
|
|
);
|
|
}
|
|
|
|
const trySeedBackground = (pixelIndex: number) => {
|
|
if (backgroundMask[pixelIndex]) {
|
|
return;
|
|
}
|
|
|
|
const offset = pixelIndex * 4;
|
|
const alpha = pixels[offset + 3] ?? 0;
|
|
const strongCandidate =
|
|
alpha < 40 ||
|
|
(greenScores[pixelIndex] ?? 0) > 0.12 ||
|
|
(whiteScores[pixelIndex] ?? 0) > 0.32;
|
|
|
|
if (!strongCandidate) {
|
|
return;
|
|
}
|
|
|
|
backgroundMask[pixelIndex] = 1;
|
|
queue.push(pixelIndex);
|
|
};
|
|
|
|
for (let x = 0; x < width; x += 1) {
|
|
trySeedBackground(x);
|
|
trySeedBackground((height - 1) * width + x);
|
|
}
|
|
|
|
for (let y = 1; y < height - 1; y += 1) {
|
|
trySeedBackground(y * width);
|
|
trySeedBackground(y * width + width - 1);
|
|
}
|
|
|
|
while (queueIndex < queue.length) {
|
|
const pixelIndex = queue[queueIndex]!;
|
|
queueIndex += 1;
|
|
|
|
const x = pixelIndex % width;
|
|
const y = Math.floor(pixelIndex / width);
|
|
const neighborIndexes = [
|
|
x > 0 ? pixelIndex - 1 : -1,
|
|
x + 1 < width ? pixelIndex + 1 : -1,
|
|
y > 0 ? pixelIndex - width : -1,
|
|
y + 1 < height ? pixelIndex + width : -1,
|
|
];
|
|
|
|
for (const nextPixelIndex of neighborIndexes) {
|
|
if (nextPixelIndex < 0 || backgroundMask[nextPixelIndex]) {
|
|
continue;
|
|
}
|
|
|
|
const nextOffset = nextPixelIndex * 4;
|
|
const nextAlpha = pixels[nextOffset + 3] ?? 0;
|
|
const nextGreenScore = greenScores[nextPixelIndex] ?? 0;
|
|
const nextWhiteScore = whiteScores[nextPixelIndex] ?? 0;
|
|
const nextHint = backgroundHints[nextPixelIndex] ?? 0;
|
|
const reachableSoftEdge =
|
|
nextHint > 0.08 &&
|
|
nextAlpha < SOFT_EDGE_ALPHA_THRESHOLD &&
|
|
(nextGreenScore > 0.04 || nextWhiteScore > 0.08 || nextAlpha < 180);
|
|
|
|
if (
|
|
nextAlpha < 40 ||
|
|
nextGreenScore > 0.12 ||
|
|
nextWhiteScore > 0.32 ||
|
|
reachableSoftEdge
|
|
) {
|
|
backgroundMask[nextPixelIndex] = 1;
|
|
queue.push(nextPixelIndex);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let iteration = 0; iteration < 2; iteration += 1) {
|
|
const expandedMask = new Uint8Array(backgroundMask);
|
|
|
|
for (let y = 0; y < height; y += 1) {
|
|
for (let x = 0; x < width; x += 1) {
|
|
const pixelIndex = y * width + x;
|
|
if (expandedMask[pixelIndex]) {
|
|
continue;
|
|
}
|
|
|
|
const alpha = pixels[pixelIndex * 4 + 3] ?? 0;
|
|
const hint = backgroundHints[pixelIndex] ?? 0;
|
|
if (alpha >= SOFT_EDGE_ALPHA_THRESHOLD || hint <= 0.06) {
|
|
continue;
|
|
}
|
|
|
|
let adjacentBackgroundCount = 0;
|
|
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
|
|
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
|
|
if (offsetX === 0 && offsetY === 0) {
|
|
continue;
|
|
}
|
|
|
|
const nextX = x + offsetX;
|
|
const nextY = y + offsetY;
|
|
if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) {
|
|
continue;
|
|
}
|
|
|
|
if (backgroundMask[nextY * width + nextX]) {
|
|
adjacentBackgroundCount += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
adjacentBackgroundCount >= 2 ||
|
|
(adjacentBackgroundCount >= 1 && hint > 0.18)
|
|
) {
|
|
expandedMask[pixelIndex] = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
backgroundMask.set(expandedMask);
|
|
}
|
|
|
|
for (let y = 0; y < height; y += 1) {
|
|
for (let x = 0; x < width; x += 1) {
|
|
const pixelIndex = y * width + x;
|
|
if (!backgroundMask[pixelIndex]) {
|
|
continue;
|
|
}
|
|
|
|
const offset = pixelIndex * 4;
|
|
const alpha = pixels[offset + 3] ?? 0;
|
|
if (alpha === 0) {
|
|
continue;
|
|
}
|
|
|
|
const matteScore = Math.max(
|
|
backgroundHints[pixelIndex] ?? 0,
|
|
greenScores[pixelIndex] ?? 0,
|
|
whiteScores[pixelIndex] ?? 0,
|
|
);
|
|
|
|
let foregroundSupport = 0;
|
|
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
|
|
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
|
|
if (offsetX === 0 && offsetY === 0) {
|
|
continue;
|
|
}
|
|
|
|
const nextX = x + offsetX;
|
|
const nextY = y + offsetY;
|
|
if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) {
|
|
continue;
|
|
}
|
|
|
|
const nextPixelIndex = nextY * width + nextX;
|
|
if (backgroundMask[nextPixelIndex]) {
|
|
continue;
|
|
}
|
|
|
|
const nextAlpha = pixels[nextPixelIndex * 4 + 3] ?? 0;
|
|
if (nextAlpha >= FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD) {
|
|
foregroundSupport += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
let nextAlpha = alpha;
|
|
if (matteScore > 0.9 || foregroundSupport === 0) {
|
|
nextAlpha = 0;
|
|
} else if (matteScore > 0.72 && foregroundSupport <= 1) {
|
|
nextAlpha = Math.min(alpha, Math.round(alpha * 0.08));
|
|
} else {
|
|
nextAlpha = Math.min(
|
|
alpha,
|
|
Math.round(alpha * Math.max(0.08, 1 - matteScore * 0.95)),
|
|
);
|
|
}
|
|
|
|
if (foregroundSupport >= 3 && matteScore < 0.55) {
|
|
nextAlpha = Math.max(nextAlpha, Math.round(alpha * 0.22));
|
|
}
|
|
|
|
if (nextAlpha < 10) {
|
|
nextAlpha = 0;
|
|
}
|
|
|
|
if (nextAlpha !== alpha) {
|
|
pixels[offset + 3] = nextAlpha;
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let y = 0; y < height; y += 1) {
|
|
for (let x = 0; x < width; x += 1) {
|
|
const pixelIndex = y * width + x;
|
|
const offset = pixelIndex * 4;
|
|
const alpha = pixels[offset + 3] ?? 0;
|
|
if (alpha === 0) {
|
|
continue;
|
|
}
|
|
|
|
let touchesTransparentEdge = false;
|
|
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
|
|
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
|
|
if (offsetX === 0 && offsetY === 0) {
|
|
continue;
|
|
}
|
|
|
|
const nextX = x + offsetX;
|
|
const nextY = y + offsetY;
|
|
if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) {
|
|
touchesTransparentEdge = true;
|
|
continue;
|
|
}
|
|
|
|
const nextPixelIndex = nextY * width + nextX;
|
|
if (
|
|
backgroundMask[nextPixelIndex] ||
|
|
(pixels[nextPixelIndex * 4 + 3] ?? 0) < 16
|
|
) {
|
|
touchesTransparentEdge = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!touchesTransparentEdge) {
|
|
continue;
|
|
}
|
|
|
|
const greenScore = greenScores[pixelIndex] ?? 0;
|
|
const whiteScore = whiteScores[pixelIndex] ?? 0;
|
|
const contamination = Math.max(
|
|
greenScore,
|
|
whiteScore,
|
|
backgroundMask[pixelIndex] ? 0.35 : 0,
|
|
alpha < 220 ? ((220 - alpha) / 220) * 0.25 : 0,
|
|
);
|
|
|
|
if (contamination < 0.06) {
|
|
continue;
|
|
}
|
|
|
|
let red = pixels[offset] ?? 0;
|
|
let green = pixels[offset + 1] ?? 0;
|
|
let blue = pixels[offset + 2] ?? 0;
|
|
const sample = collectForegroundNeighborColor(
|
|
pixels,
|
|
width,
|
|
height,
|
|
x,
|
|
y,
|
|
backgroundMask,
|
|
backgroundHints,
|
|
);
|
|
const blend = clamp01(
|
|
Math.max(contamination * 0.82, touchesTransparentEdge ? 0.22 : 0),
|
|
);
|
|
|
|
if (sample) {
|
|
red = Math.round(lerp(red, sample.red, blend));
|
|
green = Math.round(lerp(green, sample.green, blend));
|
|
blue = Math.round(lerp(blue, sample.blue, blend));
|
|
|
|
if (greenScore > 0.04) {
|
|
green = Math.min(green, sample.green + 18);
|
|
}
|
|
|
|
if (whiteScore > 0.1) {
|
|
red = Math.min(red, sample.red + 26);
|
|
green = Math.min(green, sample.green + 26);
|
|
blue = Math.min(blue, sample.blue + 26);
|
|
}
|
|
} else {
|
|
if (greenScore > 0.04) {
|
|
green = Math.max(
|
|
Math.max(red, blue),
|
|
Math.round(green - (green - Math.max(red, blue)) * 0.78),
|
|
);
|
|
}
|
|
|
|
if (whiteScore > 0.12) {
|
|
const spread = Math.max(red, green, blue) - Math.min(red, green, blue);
|
|
if (spread < 20) {
|
|
const tonedValue = Math.round(((red + green + blue) / 3) * 0.88);
|
|
red = Math.min(red, tonedValue);
|
|
green = Math.min(green, tonedValue);
|
|
blue = Math.min(blue, tonedValue);
|
|
}
|
|
}
|
|
}
|
|
|
|
let nextAlpha = alpha;
|
|
const edgeFade = Math.max(greenScore * 0.35, whiteScore * 0.28);
|
|
if (edgeFade > 0.08) {
|
|
nextAlpha = Math.min(alpha, Math.round(alpha * (1 - edgeFade)));
|
|
if (nextAlpha < 10) {
|
|
nextAlpha = 0;
|
|
}
|
|
}
|
|
|
|
if (
|
|
red !== (pixels[offset] ?? 0) ||
|
|
green !== (pixels[offset + 1] ?? 0) ||
|
|
blue !== (pixels[offset + 2] ?? 0) ||
|
|
nextAlpha !== alpha
|
|
) {
|
|
pixels[offset] = red;
|
|
pixels[offset + 1] = green;
|
|
pixels[offset + 2] = blue;
|
|
pixels[offset + 3] = nextAlpha;
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return changed;
|
|
}
|