Fix: background splitting bug

This commit is contained in:
DaZuo0122
2026-02-14 17:08:29 +08:00
parent 901bf0ffc3
commit 1fa7080210
10 changed files with 348 additions and 34 deletions

View File

@@ -33,6 +33,22 @@ export type UiSnapshot = {
};
type AnimationMap = Map<string, UiAnimationClip>;
const KEY_R = 0xff;
const KEY_G = 0x00;
const KEY_B = 0xff;
const FALLBACK_MIN_CONNECTED_RATIO = 0.005;
const CONNECTED_HUE_MIN = 270;
const CONNECTED_HUE_MAX = 350;
const CONNECTED_SAT_MIN = 0.25;
const CONNECTED_VAL_MIN = 0.08;
const FALLBACK_HUE_MIN = 255;
const FALLBACK_HUE_MAX = 355;
const FALLBACK_SAT_MIN = 0.15;
const FALLBACK_VAL_MIN = 0.04;
const HALO_HUE_MIN = 245;
const HALO_HUE_MAX = 355;
const HALO_SAT_MIN = 0.15;
const HALO_VAL_MIN = 0.04;
export class PixiPetRenderer {
private app: Application;
@@ -101,28 +117,144 @@ export class PixiPetRenderer {
ctx.drawImage(image, 0, 0);
const frame = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = frame.data;
const keyR = 0xff;
const keyG = 0x00;
const keyB = 0xff;
const hardTolerance = 22;
const softTolerance = 46;
for (let i = 0; i < data.length; i += 4) {
const dr = Math.abs(data[i] - keyR);
const dg = Math.abs(data[i + 1] - keyG);
const db = Math.abs(data[i + 2] - keyB);
const maxDistance = Math.max(dr, dg, db);
if (maxDistance <= hardTolerance) {
data[i + 3] = 0;
const width = canvas.width;
const height = canvas.height;
const pixelCount = width * height;
const isKeyLike = new Uint8Array(pixelCount);
const removedBg = new Uint8Array(pixelCount);
const queue = new Int32Array(pixelCount);
let head = 0;
let tail = 0;
const indexFor = (x: number, y: number): number => y * width + x;
const channelOffset = (index: number): number => index * 4;
const enqueueIfKeyLike = (x: number, y: number): void => {
const idx = indexFor(x, y);
if (isKeyLike[idx] === 1 && removedBg[idx] === 0) {
removedBg[idx] = 1;
queue[tail] = idx;
tail += 1;
}
};
for (let idx = 0; idx < pixelCount; idx += 1) {
const offset = channelOffset(idx);
const [h, s, v] = PixiPetRenderer.rgbToHsv(
data[offset],
data[offset + 1],
data[offset + 2]
);
if (
PixiPetRenderer.isHueInRange(h, CONNECTED_HUE_MIN, CONNECTED_HUE_MAX) &&
s >= CONNECTED_SAT_MIN &&
v >= CONNECTED_VAL_MIN
) {
isKeyLike[idx] = 1;
}
}
for (let x = 0; x < width; x += 1) {
enqueueIfKeyLike(x, 0);
enqueueIfKeyLike(x, height - 1);
}
for (let y = 1; y < height - 1; y += 1) {
enqueueIfKeyLike(0, y);
enqueueIfKeyLike(width - 1, y);
}
while (head < tail) {
const idx = queue[head];
head += 1;
const x = idx % width;
const y = Math.floor(idx / width);
if (x > 0) {
enqueueIfKeyLike(x - 1, y);
}
if (x + 1 < width) {
enqueueIfKeyLike(x + 1, y);
}
if (y > 0) {
enqueueIfKeyLike(x, y - 1);
}
if (y + 1 < height) {
enqueueIfKeyLike(x, y + 1);
}
}
const connectedRemovedCount = tail;
for (let idx = 0; idx < pixelCount; idx += 1) {
if (removedBg[idx] !== 1) {
continue;
}
if (maxDistance <= softTolerance) {
const alphaScale =
(maxDistance - hardTolerance) / (softTolerance - hardTolerance);
const suppress = 1 - alphaScale;
data[i + 3] = Math.round(data[i + 3] * alphaScale);
// Remove magenta spill from antialiased edges after alpha reduction.
data[i] = Math.round(data[i] * (1 - 0.4 * suppress));
data[i + 2] = Math.round(data[i + 2] * (1 - 0.4 * suppress));
const offset = channelOffset(idx);
data[offset + 3] = 0;
}
const needsFallback =
connectedRemovedCount / Math.max(pixelCount, 1) < FALLBACK_MIN_CONNECTED_RATIO;
if (needsFallback) {
for (let idx = 0; idx < pixelCount; idx += 1) {
const offset = channelOffset(idx);
const [h, s, v] = PixiPetRenderer.rgbToHsv(
data[offset],
data[offset + 1],
data[offset + 2]
);
const maxDistanceFromHardKey = PixiPetRenderer.maxColorDistance(
data[offset],
data[offset + 1],
data[offset + 2],
KEY_R,
KEY_G,
KEY_B
);
if (
(PixiPetRenderer.isHueInRange(h, FALLBACK_HUE_MIN, FALLBACK_HUE_MAX) &&
s >= FALLBACK_SAT_MIN &&
v >= FALLBACK_VAL_MIN) ||
maxDistanceFromHardKey <= 96
) {
data[offset + 3] = 0;
}
}
}
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const idx = indexFor(x, y);
if (data[channelOffset(idx) + 3] === 0) {
continue;
}
let touchesBackground = false;
if (x > 0 && data[channelOffset(indexFor(x - 1, y)) + 3] === 0) {
touchesBackground = true;
} else if (x + 1 < width && data[channelOffset(indexFor(x + 1, y)) + 3] === 0) {
touchesBackground = true;
} else if (y > 0 && data[channelOffset(indexFor(x, y - 1)) + 3] === 0) {
touchesBackground = true;
} else if (y + 1 < height && data[channelOffset(indexFor(x, y + 1)) + 3] === 0) {
touchesBackground = true;
}
if (!touchesBackground) {
continue;
}
const offset = channelOffset(idx);
const [h, s, v] = PixiPetRenderer.rgbToHsv(
data[offset],
data[offset + 1],
data[offset + 2]
);
if (
!PixiPetRenderer.isHueInRange(h, HALO_HUE_MIN, HALO_HUE_MAX) ||
s < HALO_SAT_MIN ||
v < HALO_VAL_MIN
) {
continue;
}
data[offset] = Math.round(data[offset] * 0.72);
data[offset + 2] = Math.round(data[offset + 2] * 0.72);
data[offset + 3] = Math.round(data[offset + 3] * 0.86);
}
}
ctx.putImageData(frame, 0, 0);
@@ -135,6 +267,53 @@ export class PixiPetRenderer {
});
}
private static maxColorDistance(
r: number,
g: number,
b: number,
keyR: number,
keyG: number,
keyB: number
): number {
const dr = Math.abs(r - keyR);
const dg = Math.abs(g - keyG);
const db = Math.abs(b - keyB);
return Math.max(dr, dg, db);
}
private static rgbToHsv(r: number, g: number, b: number): [number, number, number] {
const rf = r / 255;
const gf = g / 255;
const bf = b / 255;
const max = Math.max(rf, gf, bf);
const min = Math.min(rf, gf, bf);
const delta = max - min;
let hue = 0;
if (delta > 0) {
if (max === rf) {
hue = 60 * (((gf - bf) / delta) % 6);
} else if (max === gf) {
hue = 60 * ((bf - rf) / delta + 2);
} else {
hue = 60 * ((rf - gf) / delta + 4);
}
}
if (hue < 0) {
hue += 360;
}
const saturation = max === 0 ? 0 : delta / max;
const value = max;
return [hue, saturation, value];
}
private static isHueInRange(hue: number, min: number, max: number): boolean {
if (min <= max) {
return hue >= min && hue <= max;
}
return hue >= min || hue <= max;
}
dispose(): void {
if (this.disposed) {
return;