diff --git a/web/src/lib/core/alpha.test.ts b/web/src/lib/core/alpha.test.ts index 783a1f6..5a91335 100644 --- a/web/src/lib/core/alpha.test.ts +++ b/web/src/lib/core/alpha.test.ts @@ -3,6 +3,7 @@ import { colorMask, extractAlphaMask, flattenOntoColor, + hardenAlpha, invertAlpha, parseHex, removeColorToAlpha, @@ -11,6 +12,22 @@ import { } from './alpha'; import { makeImage } from './test-helpers'; +describe('hardenAlpha', () => { + it('бинаризует альфу по порогу, RGB не трогает', () => { + const out = hardenAlpha( + makeImage(2, 1, [ + [10, 20, 30, 100], + [40, 50, 60, 200] + ]), + 50 + ); + expect([...out.data]).toEqual([ + 10, 20, 30, 0, + 40, 50, 60, 255 + ]); + }); +}); + describe('setAlphaChannel', () => { it('задаёт константную альфу', () => { const out = setAlphaChannel(makeImage(2, 1, [ diff --git a/web/src/lib/core/alpha.ts b/web/src/lib/core/alpha.ts index c554ed6..9f29c99 100644 --- a/web/src/lib/core/alpha.ts +++ b/web/src/lib/core/alpha.ts @@ -73,6 +73,18 @@ export function invertAlpha(img: PixelImage): PixelImage { return out; } +export function hardenAlpha(img: PixelImage, thresholdPercent: number): PixelImage { + const threshold = (clamp(thresholdPercent, 0, 100) / 100) * 255; + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < out.data.length; i += 4) { + out.data[i] = img.data[i]; + out.data[i + 1] = img.data[i + 1]; + out.data[i + 2] = img.data[i + 2]; + out.data[i + 3] = img.data[i + 3] >= threshold ? 255 : 0; + } + return out; +} + export function colorMask(img: PixelImage, hex: string, tolerancePercent = 0): PixelImage { const [targetR, targetG, targetB] = parseHex(hex); const tolerance = (clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE; diff --git a/web/src/lib/core/morphology.test.ts b/web/src/lib/core/morphology.test.ts new file mode 100644 index 0000000..8dc2521 --- /dev/null +++ b/web/src/lib/core/morphology.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest'; +import { + buildAlphaMask, + closingImage, + contourImage, + dilateImage, + dilateMask, + erodeImage, + erodeMask, + openingImage, + strokeImage +} from './morphology'; +import { makeImage } from './test-helpers'; + +function maskFrom(rows: string[]): Uint8Array { + const flat = rows.join(''); + const mask = new Uint8Array(flat.length); + for (let i = 0; i < flat.length; i++) { + mask[i] = flat[i] === '#' ? 1 : 0; + } + return mask; +} + +function toRows(mask: Uint8Array, w: number): string[] { + const rows: string[] = []; + for (let y = 0; y < mask.length / w; y++) { + let row = ''; + for (let x = 0; x < w; x++) { + row += mask[y * w + x] === 1 ? '#' : '.'; + } + rows.push(row); + } + return rows; +} + +describe('dilateMask', () => { + it('одиночный пиксель r=1 превращается в плюс', () => { + const out = dilateMask( + maskFrom(['.....', '.....', '..#..', '.....', '.....']), + 5, + 5, + 1 + ); + expect(toRows(out, 5)).toEqual(['.....', '..#..', '.###.', '..#..', '.....']); + }); + + it('r=2 даёт ромб радиуса 2', () => { + const out = dilateMask( + maskFrom(['.....', '.....', '..#..', '.....', '.....']), + 5, + 5, + 2 + ); + expect(toRows(out, 5)).toEqual(['..#..', '.###.', '#####', '.###.', '..#..']); + }); +}); + +describe('erodeMask', () => { + it('сплошной объект во весь кадр не сжимается от границ', () => { + const solid = maskFrom(['#####', '#####', '#####', '#####', '#####']); + expect([...erodeMask(solid, 5, 5, 1)]).toEqual([...solid]); + }); + + it('изолированный пиксель исчезает', () => { + const out = erodeMask( + maskFrom(['.....', '.....', '..#..', '.....', '.....']), + 5, + 5, + 1 + ); + expect([...out].every((v) => v === 0)).toBe(true); + }); +}); + +describe('opening/closing образы', () => { + it('opening убирает отстоящий мусорный пиксель и сохраняет блок', () => { + const B = [10, 10, 10, 255]; + const T = [0, 0, 0, 0]; + const S = [200, 200, 200, 255]; + const out = openingImage( + makeImage( + 6, + 3, + [B, B, B, T, S, T, B, B, B, T, T, T, B, B, B, T, T, T] + ), + 1 + ); + const at = (x: number, y: number) => out.data[(y * 6 + x) * 4 + 3]; + expect(at(0, 1)).toBe(255); + expect(at(2, 1)).toBe(255); + expect(at(4, 1)).toBe(0); + }); + + it('closing заполняет одиночную прозрачную дыру чёрным', () => { + const pixels: number[][] = []; + for (let y = 0; y < 3; y++) { + for (let x = 0; x < 3; x++) { + pixels.push(x === 1 && y === 1 ? [0, 0, 0, 0] : [255, 255, 255, 255]); + } + } + const out = closingImage(makeImage(3, 3, pixels), 1); + const di = (1 * 3 + 1) * 4; + expect(out.data[di + 3]).toBe(255); + }); +}); + +describe('image-обёртки', () => { + it('dilateImage сохраняет RGB содержимого, новые пиксели непрозрачны', () => { + const T = [0, 0, 0, 0]; + const O = [200, 50, 25, 255]; + const img = makeImage(5, 2, [T, O, T, T, T, T, T, T, T, T]); + const out = dilateImage(img, 1); + const at = (x: number, y: number) => { + const di = (y * 5 + x) * 4; + return [out.data[di], out.data[di + 1], out.data[di + 2], out.data[di + 3]]; + }; + expect(at(1, 0)).toEqual([200, 50, 25, 255]); + expect(at(0, 0)).toEqual([0, 0, 0, 255]); + expect(at(4, 0)[3]).toBe(0); + }); + + it('erodeImage не стирает объект у края кадра', () => { + const img = makeImage(2, 2, [ + [10, 20, 30, 255], + [10, 20, 30, 255], + [10, 20, 30, 255], + [10, 20, 30, 255] + ]); + const out = erodeImage(img, 1); + for (let i = 0; i < out.data.length; i += 4) { + expect(out.data[i + 3]).toBe(255); + } + }); +}); + +describe('strokeImage', () => { + it('кольцо цвета обводки вокруг квадрата', () => { + const pixels: number[][] = []; + for (let y = 0; y < 7; y++) { + for (let x = 0; x < 7; x++) { + pixels.push(x >= 2 && x <= 4 && y >= 2 && y <= 4 ? [255, 0, 0, 255] : [0, 0, 0, 0]); + } + } + const out = strokeImage(makeImage(7, 7, pixels), 1, '#0000ff'); + const at = (x: number, y: number) => + [...out.data.slice((y * 7 + x) * 4, (y * 7 + x) * 4 + 4)]; + expect(at(2, 3)).toEqual([255, 0, 0, 255]); + expect(at(1, 3)).toEqual([0, 0, 255, 255]); + expect(at(0, 0)).toEqual([0, 0, 0, 0]); + }); +}); + +describe('contourImage', () => { + it('линия по краю квадрата, центр прозрачен', () => { + const pixels: number[][] = []; + for (let y = 0; y < 7; y++) { + for (let x = 0; x < 7; x++) { + pixels.push(x >= 2 && x <= 4 && y >= 2 && y <= 4 ? [255, 0, 0, 255] : [0, 0, 0, 0]); + } + } + const out = contourImage(makeImage(7, 7, pixels), 1, '#0000ff'); + const at = (x: number, y: number) => + [...out.data.slice((y * 7 + x) * 4, (y * 7 + x) * 4 + 4)]; + expect(at(2, 2)).toEqual([0, 0, 255, 255]); + expect(at(3, 3)).toEqual([0, 0, 0, 0]); + expect(at(1, 3)).toEqual([0, 0, 0, 0]); + }); +}); diff --git a/web/src/lib/core/morphology.ts b/web/src/lib/core/morphology.ts new file mode 100644 index 0000000..dcdbc0b --- /dev/null +++ b/web/src/lib/core/morphology.ts @@ -0,0 +1,192 @@ +import { parseHex } from './alpha'; +import { clonePixelImage, createPixelImage, type PixelImage } from './types'; + +type Mask = Uint8Array; + +type Offset = { dx: number; dy: number }; + +function discOffsets(radius: number): Offset[] { + const offsets: Offset[] = []; + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + if (dx * dx + dy * dy <= radius * radius) { + offsets.push({ dx, dy }); + } + } + } + return offsets; +} + +export function buildAlphaMask(img: PixelImage): Mask { + const mask = new Uint8Array(img.width * img.height); + for (let i = 0; i < mask.length; i++) { + mask[i] = img.data[i * 4 + 3] > 0 ? 1 : 0; + } + return mask; +} + +export function dilateMask(mask: Mask, width: number, height: number, radius: number): Mask { + const r = Math.trunc(radius); + if (r < 1) return mask.slice(); + const out = new Uint8Array(mask.length); + const offsets = discOffsets(r); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let hit = 0; + for (const { dx, dy } of offsets) { + const sx = x + dx; + const sy = y + dy; + if (sx < 0 || sy < 0 || sx >= width || sy >= height) continue; + if (mask[sy * width + sx] === 1) { + hit = 1; + break; + } + } + out[y * width + x] = hit; + } + } + return out; +} + +export function erodeMask(mask: Mask, width: number, height: number, radius: number): Mask { + const r = Math.trunc(radius); + if (r < 1) return mask.slice(); + const out = new Uint8Array(mask.length); + const offsets = discOffsets(r); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let solid = 1; + for (const { dx, dy } of offsets) { + const sx = x + dx; + const sy = y + dy; + if (sx < 0 || sy < 0 || sx >= width || sy >= height) continue; + if (mask[sy * width + sx] !== 1) { + solid = 0; + break; + } + } + out[y * width + x] = solid; + } + } + return out; +} + +export function applyMaskAlpha(img: PixelImage, mask: Mask): PixelImage { + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < mask.length; i++) { + const di = i * 4; + if (mask[i] === 1) { + 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] = 255; + } else { + out.data[di + 3] = 0; + } + } + return out; +} + +export function dilateImage(img: PixelImage, radiusPx: number): PixelImage { + if (radiusPx < 1) return clonePixelImage(img); + return applyMaskAlpha(img, dilateMask(buildAlphaMask(img), img.width, img.height, radiusPx)); +} + +export function erodeImage(img: PixelImage, radiusPx: number): PixelImage { + if (radiusPx < 1) return clonePixelImage(img); + return applyMaskAlpha(img, erodeMask(buildAlphaMask(img), img.width, img.height, radiusPx)); +} + +export function strokeImage( + img: PixelImage, + radiusPx: number, + colorHex: string +): PixelImage { + const r = Math.trunc(radiusPx); + if (r < 1) return clonePixelImage(img); + const [cr, cg, cb] = parseHex(colorHex); + const mask = buildAlphaMask(img); + const ring = dilateMask(mask, img.width, img.height, r); + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < ring.length; i++) { + const di = i * 4; + if (ring[i] === 1 && mask[i] === 0) { + out.data[di] = cr; + out.data[di + 1] = cg; + out.data[di + 2] = cb; + out.data[di + 3] = 255; + } else if (mask[i] === 1) { + 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] = img.data[di + 3]; + } + } + return out; +} + +export function contourImage(img: PixelImage, radiusPx: number, colorHex: string): PixelImage { + const r = Math.max(1, Math.trunc(radiusPx)); + const [cr, cg, cb] = parseHex(colorHex); + const mask = buildAlphaMask(img); + const inner = erodeMask(mask, img.width, img.height, r); + const line = new Uint8Array(mask.length); + for (let i = 0; i < mask.length; i++) { + line[i] = mask[i] === 1 && inner[i] === 0 ? 1 : 0; + } + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < line.length; i++) { + const di = i * 4; + if (line[i] === 1) { + out.data[di] = cr; + out.data[di + 1] = cg; + out.data[di + 2] = cb; + out.data[di + 3] = 255; + } + } + return out; +} + +export function openingMask(mask: Mask, w: number, h: number, radius: number): Mask { + return dilateMask(erodeMask(mask, w, h, radius), w, h, radius); +} + +export function closingMask(mask: Mask, w: number, h: number, radius: number): Mask { + return erodeMask(dilateMask(mask, w, h, radius), w, h, radius); +} + +export function openingImage(img: PixelImage, radiusPx: number): PixelImage { + if (radiusPx < 1) return clonePixelImage(img); + const mask = buildAlphaMask(img); + const opened = openingMask(mask, img.width, img.height, radiusPx); + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < opened.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] = opened[i] === 1 ? img.data[di + 3] : 0; + } + return out; +} + +export function closingImage(img: PixelImage, radiusPx: number): PixelImage { + if (radiusPx < 1) return clonePixelImage(img); + const mask = buildAlphaMask(img); + const closed = closingMask(mask, img.width, img.height, radiusPx); + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < closed.length; i++) { + const di = i * 4; + if (closed[i] === 1 && mask[i] === 0) { + out.data[di] = 0; + out.data[di + 1] = 0; + out.data[di + 2] = 0; + } else { + 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] = closed[i] === 1 ? 255 : img.data[di + 3]; + } + return out; +}