feat: add alpha and morphology core tools

This commit is contained in:
2026-08-24 12:02:42 +05:00
parent b434bf3ef3
commit cb5ccf6da8
4 changed files with 389 additions and 0 deletions
+168
View File
@@ -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]);
});
});