diff --git a/web/src/lib/core/background.test.ts b/web/src/lib/core/background.test.ts new file mode 100644 index 0000000..2657a1e --- /dev/null +++ b/web/src/lib/core/background.test.ts @@ -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 + ]); + }); +}); diff --git a/web/src/lib/core/background.ts b/web/src/lib/core/background.ts new file mode 100644 index 0000000..464da35 --- /dev/null +++ b/web/src/lib/core/background.ts @@ -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)); +} diff --git a/web/src/lib/core/convolution.test.ts b/web/src/lib/core/convolution.test.ts new file mode 100644 index 0000000..bf33d77 --- /dev/null +++ b/web/src/lib/core/convolution.test.ts @@ -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] + ]); + } + } + }); +}); diff --git a/web/src/lib/core/convolution.ts b/web/src/lib/core/convolution.ts new file mode 100644 index 0000000..9fcc6ec --- /dev/null +++ b/web/src/lib/core/convolution.ts @@ -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))); +}