mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 13:36:36 +00:00
feat: add alpha and morphology core tools
This commit is contained in:
@@ -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, [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user