feat: add background and convolution core tools

This commit is contained in:
2026-08-24 07:25:41 +05:00
parent 3d7371492f
commit fd5236c19b
4 changed files with 570 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import {
backgroundMaskPreview,
backgroundRemovalMask,
removeBackground
} from './background';
import { makeImage } from './test-helpers';
const GREEN = [0, 255, 0, 255];
const RED = [255, 0, 0, 255];
describe('backgroundRemovalMask', () => {
it('глобальный режим удаляет все совпадающие пиксели', () => {
const mask = backgroundRemovalMask(
makeImage(2, 1, [GREEN, RED]),
{ color: '#00ff00', tolerancePercent: 0, outerOnly: false, smoothPasses: 0 }
);
expect([...mask]).toEqual([1, 0]);
});
describe('режим внешних областей', () => {
const ringRedCenterGreen = [
RED, RED, RED,
RED, GREEN, RED,
RED, RED, RED
];
it('заливка от краёв не достаёт до изолированного совпадающего острова', () => {
const mask = backgroundRemovalMask(
makeImage(3, 3, ringRedCenterGreen),
{ color: '#00ff00', tolerancePercent: 0, outerOnly: true, smoothPasses: 0 }
);
expect(mask[4]).toBe(0);
});
it('глобальный режим удаляет и изолированный остров', () => {
const mask = backgroundRemovalMask(
makeImage(3, 3, ringRedCenterGreen),
{ color: '#00ff00', tolerancePercent: 0, outerOnly: false, smoothPasses: 0 }
);
expect(mask[4]).toBe(1);
expect(mask[0]).toBe(0);
});
});
it('допуск расширяет захват по цветовому расстоянию', () => {
const img = makeImage(2, 1, [
[10, 10, 10, 255],
[128, 128, 128, 255]
]);
const tight = backgroundRemovalMask(img, {
color: '#000000', tolerancePercent: 40, outerOnly: false, smoothPasses: 0
});
const wide = backgroundRemovalMask(img, {
color: '#000000', tolerancePercent: 60, outerOnly: false, smoothPasses: 0
});
expect(tight[1]).toBe(0);
expect(wide[1]).toBe(1);
});
});
describe('smoothMask-поведение через backgroundRemovalMask', () => {
const ringRedCenterGreen = [
RED, RED, RED,
RED, GREEN, RED,
RED, RED, RED
];
const opts = { color: '#00ff00', tolerancePercent: 0, outerOnly: false };
const alphaAtCenter = (img: { data: Uint8ClampedArray }) => img.data[19];
it('без сглаживания центр удалён', () => {
const img = removeBackground(makeImage(3, 3, ringRedCenterGreen), { ...opts, smoothPasses: 0 });
expect(alphaAtCenter(img)).toBe(0);
});
it('два прохода мажоритарного фильтра возвращают изолированный пиксель', () => {
const img = removeBackground(makeImage(3, 3, ringRedCenterGreen), { ...opts, smoothPasses: 2 });
expect(alphaAtCenter(img)).toBe(255);
});
});
describe('removeBackground', () => {
it('обнуляет альфу удалённых, сохраняет RGB остальных', () => {
const out = removeBackground(
makeImage(2, 1, [GREEN, [5, 6, 7, 200]]),
{ color: '#00ff00', tolerancePercent: 0, outerOnly: false, smoothPasses: 0 }
);
expect(out.data[3]).toBe(0);
expect([...out.data.slice(4, 8)]).toEqual([5, 6, 7, 200]);
});
});
describe('backgroundMaskPreview', () => {
it('белое там, где удаление, чёрное — где остаёмся, всё непрозрачно', () => {
const preview = backgroundMaskPreview(
makeImage(2, 1, [
GREEN,
[9, 9, 9, 60]
]),
{ color: '#00ff00', tolerancePercent: 0, outerOnly: false, smoothPasses: 0 }
);
expect([...preview.data]).toEqual([
255, 255, 255, 255,
0, 0, 0, 255
]);
});
});
+131
View File
@@ -0,0 +1,131 @@
import { parseHex } from './alpha';
import { createPixelImage, type PixelImage } from './types';
export type BackgroundOptions = {
color: string;
tolerancePercent: number;
outerOnly: boolean;
smoothPasses: number;
};
function buildRawMask(
img: PixelImage,
targetR: number,
targetG: number,
targetB: number,
tolerancePercent: number
): Uint8Array {
const tolerance = (clamp(tolerancePercent, 0, 100) / 100) * Math.sqrt(3 * 255 * 255);
const thresholdSq = tolerance * tolerance;
const mask = new Uint8Array(img.width * img.height);
for (let i = 0; i < mask.length; i++) {
const dr = img.data[i * 4] - targetR;
const dg = img.data[i * 4 + 1] - targetG;
const db = img.data[i * 4 + 2] - targetB;
mask[i] = dr * dr + dg * dg + db * db <= thresholdSq ? 1 : 0;
}
return mask;
}
function floodFromBorders(mask: Uint8Array, w: number, h: number): void {
const queue: number[] = [];
const push = (index: number) => {
if (mask[index] === 1) {
mask[index] = 2;
queue.push(index);
}
};
for (let x = 0; x < w; x++) {
push(x);
push((h - 1) * w + x);
}
for (let y = 0; y < h; y++) {
push(y * w);
push(y * w + w - 1);
}
let head = 0;
while (head < queue.length) {
const index = queue[head++];
const x = index % w;
if (x > 0) push(index - 1);
if (x < w - 1) push(index + 1);
if (index >= w) push(index - w);
if (index < (h - 1) * w) push(index + w);
}
for (let i = 0; i < mask.length; i++) {
mask[i] = mask[i] === 2 ? 1 : 0;
}
}
export function smoothMask(
mask: Uint8Array,
w: number,
h: number,
passes: number
): Uint8Array {
let current = mask;
const count = clamp(Math.trunc(passes), 0, 8);
for (let pass = 0; pass < count; pass++) {
const next = new Uint8Array(current.length);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
let removed = 0;
let total = 0;
for (let dy = -1; dy <= 1; dy++) {
const sy = clamp(y + dy, 0, h - 1);
for (let dx = -1; dx <= 1; dx++) {
const sx = clamp(x + dx, 0, w - 1);
removed += current[sy * w + sx];
total++;
}
}
next[y * w + x] = removed * 2 >= total ? 1 : 0;
}
}
current = next;
}
return current;
}
export function backgroundRemovalMask(
img: PixelImage,
options: BackgroundOptions
): Uint8Array {
const [tr, tg, tb] = parseHex(options.color);
const mask = buildRawMask(img, tr, tg, tb, options.tolerancePercent);
if (options.outerOnly) {
floodFromBorders(mask, img.width, img.height);
}
return smoothMask(mask, img.width, img.height, options.smoothPasses);
}
export function removeBackground(img: PixelImage, options: BackgroundOptions): PixelImage {
const mask = backgroundRemovalMask(img, options);
const out = createPixelImage(img.width, img.height);
for (let i = 0; i < mask.length; i++) {
const di = i * 4;
out.data[di] = img.data[di];
out.data[di + 1] = img.data[di + 1];
out.data[di + 2] = img.data[di + 2];
out.data[di + 3] = mask[i] === 1 ? 0 : img.data[di + 3];
}
return out;
}
export function backgroundMaskPreview(img: PixelImage, options: BackgroundOptions): PixelImage {
const mask = backgroundRemovalMask(img, options);
const out = createPixelImage(img.width, img.height);
for (let i = 0; i < mask.length; i++) {
const v = mask[i] === 1 ? 255 : 0;
const di = i * 4;
out.data[di] = v;
out.data[di + 1] = v;
out.data[di + 2] = v;
out.data[di + 3] = 255;
}
return out;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
+154
View File
@@ -0,0 +1,154 @@
import { describe, expect, it } from 'vitest';
import { convolve, gaussianBlur, sharpen } from './convolution';
import { makeImage } from './test-helpers';
const SHARPEN_KERNEL = [0, -1, 0, -1, 5, -1, 0, -1, 0];
describe('convolve', () => {
it('крестовое ядро резкости на полоске из трёх пикселей', () => {
const out = convolve(
makeImage(3, 1, [
[0, 0, 0, 255],
[100, 100, 100, 255],
[0, 0, 0, 255]
]),
SHARPEN_KERNEL,
3
);
expect([...out.data.slice(4, 8)]).toEqual([255, 255, 255, 255]);
expect([...out.data.slice(0, 4)]).toEqual([0, 0, 0, 255]);
expect([...out.data.slice(8, 12)]).toEqual([0, 0, 0, 255]);
});
it.each([
[2, 3],
[3.5, 3],
[0, 3]
])('бросает ошибку на некорректном размере ядра %i', (size) => {
expect(() => convolve(makeImage(1, 1, [[0, 0, 0, 255]]), [1], size as number)).toThrow();
});
});
describe('sharpen', () => {
it('сила 0 возвращает копию', () => {
const img = makeImage(2, 2, [
[10, 20, 30, 255],
[40, 50, 60, 128],
[70, 80, 90, 255],
[100, 110, 120, 200]
]);
expect([...sharpen(img, 0).data]).toEqual([...img.data]);
});
it('сила 100 применяет чистое ядро резкости', () => {
const out = sharpen(
makeImage(3, 1, [
[0, 0, 0, 255],
[100, 100, 100, 255],
[0, 0, 0, 255]
]),
100
);
expect(out.data[4]).toBe(255);
expect(out.data[0]).toBe(0);
});
});
describe('gaussianBlur', () => {
it('постоянное изображение не меняется ни в RGB, ни в альфе', () => {
const img = makeImage(3, 3, new Array(9).fill([40, 80, 120, 128]));
const out = gaussianBlur(img, 16);
for (let i = 0; i < out.data.length; i++) {
expect(out.data[i]).toBe(img.data[i]);
}
});
it('далёкие углы остаются прозрачными, цвет центра не искажается', () => {
const size = 61;
const pixels: number[][] = [];
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
pixels.push(
x >= 26 && x <= 34 && y >= 26 && y <= 34 ? [200, 50, 25, 255] : [0, 0, 0, 0]
);
}
}
const out = gaussianBlur(makeImage(size, size, pixels), 8);
const corner = 0;
expect(out.data[corner]).toBe(0);
expect(out.data[corner + 1]).toBe(0);
expect(out.data[corner + 2]).toBe(0);
expect(out.data[corner + 3]).toBe(0);
const center = (30 * size + 30) * 4;
expect(out.data[center]).toBe(200);
expect(out.data[center + 1]).toBe(50);
expect(out.data[center + 2]).toBe(25);
});
it('симметричный вход даёт симметричный результат', () => {
const leftByRow = [
[
[255, 0, 0, 255],
[10, 20, 30, 255],
[64, 64, 64, 64],
[5, 5, 5, 200]
],
[
[10, 20, 30, 255],
[200, 100, 50, 255],
[1, 2, 3, 4],
[90, 90, 90, 250]
],
[
[64, 64, 64, 64],
[1, 2, 3, 4],
[128, 128, 128, 128],
[40, 40, 40, 240]
],
[
[200, 100, 50, 255],
[90, 90, 90, 250],
[40, 40, 40, 240],
[7, 7, 7, 255]
],
[
[10, 20, 30, 255],
[1, 2, 3, 4],
[64, 64, 64, 64],
[90, 90, 90, 250]
],
[
[64, 64, 64, 64],
[200, 100, 50, 255],
[5, 5, 5, 200],
[1, 2, 3, 4]
],
[
[5, 5, 5, 200],
[40, 40, 40, 240],
[90, 90, 90, 250],
[128, 128, 128, 128]
]
];
const pixels: number[][] = [];
for (let y = 0; y < 7; y++) {
for (let x = 0; x < 7; x++) {
pixels.push(leftByRow[y][Math.min(x, 6 - x)]);
}
}
const img = makeImage(7, 7, pixels);
const blurred = gaussianBlur(img, 3);
for (let y = 0; y < 7; y++) {
for (let x = 0; x < 3; x++) {
const li = (y * 7 + x) * 4;
const ri = (y * 7 + (6 - x)) * 4;
expect([...blurred.data.slice(li, li + 4)]).toEqual([
blurred.data[ri],
blurred.data[ri + 1],
blurred.data[ri + 2],
blurred.data[ri + 3]
]);
}
}
});
});
+177
View File
@@ -0,0 +1,177 @@
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
type Plane = Float64Array;
export function convolve(
img: PixelImage,
kernel: readonly number[],
size: number
): PixelImage {
if (!Number.isInteger(size) || size < 1 || size % 2 === 0) {
throw new Error('Размер ядра должен быть нечётным положительным числом');
}
if (kernel.length !== size * size) {
throw new Error('Длина ядра не совпадает с его размером');
}
const half = Math.floor(size / 2);
const out = createPixelImage(img.width, img.height);
for (let y = 0; y < out.height; y++) {
for (let x = 0; x < out.width; x++) {
for (let ch = 0; ch < 3; ch++) {
let acc = 0;
for (let ky = 0; ky < size; ky++) {
const sy = clampInt(y + ky - half, 0, img.height - 1);
for (let kx = 0; kx < size; kx++) {
const sx = clampInt(x + kx - half, 0, img.width - 1);
acc += img.data[(sy * img.width + sx) * 4 + ch] * kernel[ky * size + kx];
}
}
out.data[(y * out.width + x) * 4 + ch] = acc;
}
out.data[(y * out.width + x) * 4 + 3] = img.data[(y * img.width + x) * 4 + 3];
}
}
return out;
}
const SHARPEN_KERNEL = [0, -1, 0, -1, 5, -1, 0, -1, 0];
export function sharpen(img: PixelImage, strengthPercent: number): PixelImage {
const strength = clamp(strengthPercent, 0, 100) / 100;
if (strength === 0) return clonePixelImage(img);
const sharp = convolve(img, SHARPEN_KERNEL, 3);
const out = createPixelImage(img.width, img.height);
for (let i = 0; i < out.data.length; i += 4) {
for (let ch = 0; ch < 3; ch++) {
out.data[i + ch] =
img.data[i + ch] * (1 - strength) + sharp.data[i + ch] * strength;
}
out.data[i + 3] = img.data[i + 3];
}
return out;
}
export function gaussianBlur(img: PixelImage, radiusPx: number): PixelImage {
const radius = clamp(radiusPx, 0, 512);
if (radius < 1) return clonePixelImage(img);
const sigma = Math.max(0.25, radius / 2);
const boxes = boxesForGauss(sigma, 3);
const w = img.width;
const h = img.height;
const pixels = w * h;
const red = new Float64Array(pixels);
const green = new Float64Array(pixels);
const blue = new Float64Array(pixels);
const alpha = new Float64Array(pixels);
for (let i = 0; i < pixels; i++) {
const a = img.data[i * 4 + 3] / 255;
red[i] = (img.data[i * 4] / 255) * a;
green[i] = (img.data[i * 4 + 1] / 255) * a;
blue[i] = (img.data[i * 4 + 2] / 255) * a;
alpha[i] = a;
}
const tmp = new Float64Array(pixels);
for (const box of boxes) {
const r = Math.max(0, (box - 1) / 2);
if (r < 1) continue;
blurPlanePass(red, tmp, w, h, r);
blurPlanePass(green, tmp, w, h, r);
blurPlanePass(blue, tmp, w, h, r);
blurPlanePass(alpha, tmp, w, h, r);
}
const out = createPixelImage(w, h);
for (let i = 0; i < pixels; i++) {
const a = alpha[i];
const di = i * 4;
if (a < 1 / 255) {
out.data[di] = 0;
out.data[di + 1] = 0;
out.data[di + 2] = 0;
out.data[di + 3] = 0;
continue;
}
out.data[di] = (red[i] / a) * 255;
out.data[di + 1] = (green[i] / a) * 255;
out.data[di + 2] = (blue[i] / a) * 255;
out.data[di + 3] = a * 255;
}
return out;
}
function blurPlanePass(plane: Plane, tmp: Plane, w: number, h: number, r: number): void {
blurPlaneHorizontal(plane, tmp, w, h, r);
blurPlaneVertical(tmp, plane, w, h, r);
}
function blurPlaneHorizontal(
src: Plane,
dst: Plane,
w: number,
h: number,
r: number
): void {
const div = 2 * r + 1;
const inv = 1 / div;
for (let y = 0; y < h; y++) {
const row = y * w;
let sum = src[row] * (r + 1);
for (let k = 1; k <= r; k++) {
sum += src[row + clampInt(k, 0, w - 1)];
}
for (let x = 0; x < w; x++) {
dst[row + x] = sum * inv;
const addIndex = clampInt(x + r + 1, 0, w - 1);
const removeIndex = clampInt(x - r, 0, w - 1);
sum += src[row + addIndex] - src[row + removeIndex];
}
}
}
function blurPlaneVertical(
src: Plane,
dst: Plane,
w: number,
h: number,
r: number
): void {
const div = 2 * r + 1;
const inv = 1 / div;
for (let x = 0; x < w; x++) {
let sum = src[x] * (r + 1);
for (let k = 1; k <= r; k++) {
sum += src[clampInt(k, 0, h - 1) * w + x];
}
for (let y = 0; y < h; y++) {
dst[y * w + x] = sum * inv;
const addIndex = clampInt(y + r + 1, 0, h - 1);
const removeIndex = clampInt(y - r, 0, h - 1);
sum += src[addIndex * w + x] - src[removeIndex * w + x];
}
}
}
function boxesForGauss(sigma: number, boxes: number): number[] {
const wIdeal = Math.sqrt((12 * sigma * sigma) / boxes + 1);
let wl = Math.floor(wIdeal);
if (wl % 2 === 0) wl--;
const wu = wl + 2;
const mIdeal = (12 * sigma * sigma - boxes * wl * wl - boxes * wl - boxes) / (4 * wl + 4);
const m = Math.round(mIdeal);
const sizes: number[] = [];
for (let i = 0; i < boxes; i++) {
sizes.push(i < m ? wl : wu);
}
return sizes;
}
function clamp(v: number, min: number, max: number): number {
return Math.min(max, Math.max(min, v));
}
function clampInt(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, Math.trunc(value)));
}