feat: add masks core tools
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
extractByColor,
|
||||
isGrayscaleish,
|
||||
luma01,
|
||||
rarityPredicate,
|
||||
renderPredicateMask
|
||||
} from './masks';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
const px = makeImage(2, 1, [
|
||||
[255, 0, 0, 255],
|
||||
[10, 10, 10, 255]
|
||||
]);
|
||||
|
||||
describe('isGrayscaleish / luma01', () => {
|
||||
it('серый распознаётся с допуском, цветной — нет', () => {
|
||||
expect(isGrayscaleish(10, 10, 10, 0)).toBe(true);
|
||||
expect(isGrayscaleish(12, 10, 11, 2)).toBe(true);
|
||||
expect(isGrayscaleish(255, 0, 0, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('luma01: белый 1, чёрный 0', () => {
|
||||
expect(luma01(255, 255, 255)).toBeCloseTo(1);
|
||||
expect(luma01(0, 0, 0)).toBeCloseTo(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderPredicateMask', () => {
|
||||
it('binary: совпавшие белые на чёрном, альфа принудительная', () => {
|
||||
const out = renderPredicateMask(
|
||||
px,
|
||||
(r) => r > 200,
|
||||
{ mode: 'binary' }
|
||||
);
|
||||
expect([...out.data.slice(0, 4)]).toEqual([255, 255, 255, 255]);
|
||||
expect([...out.data.slice(4, 8)]).toEqual([0, 0, 0, 255]);
|
||||
});
|
||||
|
||||
it('highlight: подкрашивает только совпавшие, остальные без изменений', () => {
|
||||
const out = renderPredicateMask(
|
||||
px,
|
||||
(r) => r > 200,
|
||||
{ mode: 'highlight', color: '#0000ff', opacityPercent: 100 }
|
||||
);
|
||||
expect([...out.data.slice(0, 4)]).toEqual([0, 0, 255, 255]);
|
||||
expect([...out.data.slice(4, 8)]).toEqual([10, 10, 10, 255]);
|
||||
});
|
||||
|
||||
it('прозрачность оригинала сохраняется в highlight-режиме', () => {
|
||||
const semi = makeImage(1, 1, [[200, 200, 200, 128]]);
|
||||
const out = renderPredicateMask(semi, () => true, {
|
||||
mode: 'highlight',
|
||||
color: '#00ff00',
|
||||
opacityPercent: 50
|
||||
});
|
||||
expect(out.data[3]).toBe(128);
|
||||
expect(out.data[0]).toBeGreaterThan(90);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rarityPredicate + extractByColor', () => {
|
||||
const img = makeImage(3, 1, [
|
||||
[255, 0, 0, 255],
|
||||
[255, 0, 0, 255],
|
||||
[0, 255, 0, 255]
|
||||
]);
|
||||
|
||||
it('уникальный (единичный) цвет находится, массовый — нет', () => {
|
||||
const pred = rarityPredicate(img, 1);
|
||||
expect(pred(0, 255, 0, 255)).toBe(true);
|
||||
expect(pred(255, 0, 0, 255)).toBe(false);
|
||||
});
|
||||
|
||||
it('extractByColor оставляет близкие и делает дальние прозрачными', () => {
|
||||
const out = extractByColor(px, '#0a0a0a', 5);
|
||||
expect(out.data[3]).toBe(0);
|
||||
expect(out.data[7]).toBe(255);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { hexToRgb } from './palette';
|
||||
import type { PixelImage } from './types';
|
||||
import { createPixelImage } from './types';
|
||||
|
||||
export type MaskMode = 'binary' | 'highlight';
|
||||
|
||||
export interface MaskOptions {
|
||||
/** binary: белое/чёрное без альфы; highlight: подкрасить совпавшие пиксели цветом. */
|
||||
mode?: MaskMode;
|
||||
color?: string;
|
||||
opacityPercent?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Единый рендер масок: предикат решает, совпал ли пиксель;
|
||||
* режим определяет вид результата.
|
||||
*/
|
||||
export function renderPredicateMask(
|
||||
img: PixelImage,
|
||||
predicate: (r: number, g: number, b: number, a: number) => boolean,
|
||||
o: MaskOptions = {}
|
||||
): PixelImage {
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
const highlight = o.mode !== 'binary';
|
||||
const tint = o.color ? hexToRgb(o.color) : { r: 255, g: 0, b: 170 };
|
||||
const opacity = Math.min(Math.max(o.opacityPercent ?? 70, 0), 100) / 100;
|
||||
if (!highlight) {
|
||||
// Бинарная маска — непрозрачное белое-на-чёрном
|
||||
for (let a = 3; a < out.data.length; a += 4) out.data[a] = 255;
|
||||
}
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
const r = img.data[i];
|
||||
const g = img.data[i + 1];
|
||||
const b = img.data[i + 2];
|
||||
const a = img.data[i + 3];
|
||||
if (!predicate(r, g, b, a)) {
|
||||
if (highlight && o.mode === 'highlight') {
|
||||
out.data[i] = r;
|
||||
out.data[i + 1] = g;
|
||||
out.data[i + 2] = b;
|
||||
out.data[i + 3] = a;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!highlight) {
|
||||
out.data[i] = 255;
|
||||
out.data[i + 1] = 255;
|
||||
out.data[i + 2] = 255;
|
||||
out.data[i + 3] = 255;
|
||||
continue;
|
||||
}
|
||||
out.data[i] = Math.round(r * (1 - opacity) + tint.r * opacity);
|
||||
out.data[i + 1] = Math.round(g * (1 - opacity) + tint.g * opacity);
|
||||
out.data[i + 2] = Math.round(b * (1 - opacity) + tint.b * opacity);
|
||||
out.data[i + 3] = a;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function maxChannelDelta(r1: number, g1: number, b1: number, r2: number, g2: number, b2: number): number {
|
||||
return Math.max(Math.abs(r1 - r2), Math.abs(g1 - g2), Math.abs(b1 - b2));
|
||||
}
|
||||
|
||||
export function isGrayscaleish(r: number, g: number, b: number, tolerance: number): boolean {
|
||||
return (
|
||||
Math.abs(r - g) <= tolerance && Math.abs(g - b) <= tolerance && Math.abs(r - b) <= tolerance
|
||||
);
|
||||
}
|
||||
|
||||
export function luma01(r: number, g: number, b: number): number {
|
||||
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
}
|
||||
|
||||
/**
|
||||
* Оставляет пиксели, близкие к целевому цвету, остальное делает прозрачным.
|
||||
* Допуск — в процентах от максимального поканального разброса (255).
|
||||
*/
|
||||
export function extractByColor(
|
||||
img: PixelImage,
|
||||
targetHex: string,
|
||||
tolerancePercent: number
|
||||
): PixelImage {
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
const t = hexToRgb(targetHex);
|
||||
const tol = (Math.min(Math.max(tolerancePercent, 0), 100) / 100) * 255;
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
if (maxChannelDelta(img.data[i], img.data[i + 1], img.data[i + 2], t.r, t.g, t.b) <= tol) {
|
||||
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];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Считает частоты цветов и возвращает предикат «встречается не чаще limit раз».
|
||||
*/
|
||||
export function rarityPredicate(
|
||||
img: PixelImage,
|
||||
limit: number
|
||||
): (r: number, g: number, b: number, a: number) => boolean {
|
||||
const counts = new Map<number, number>();
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
const key = (img.data[i] << 16) | (img.data[i + 1] << 8) | img.data[i + 2];
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
return (r, g, b) => {
|
||||
const key = (r << 16) | (g << 8) | b;
|
||||
return (counts.get(key) ?? 0) <= limit;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -613,6 +613,67 @@ export const ru: Dict = {
|
||||
display: { gray: 'Градациями серого', color: 'Пространство как RGB' }
|
||||
}
|
||||
},
|
||||
'show-transparent-png': {
|
||||
title: 'Показать прозрачные области PNG',
|
||||
description:
|
||||
'Подсвечивает выбранным цветом каждый прозрачный или полупрозрачный пиксель — дыры становятся заметными.',
|
||||
params: {
|
||||
mode: 'Режим маски',
|
||||
color: 'Цвет подсветки',
|
||||
opacity: 'Непрозрачность подсветки, %'
|
||||
},
|
||||
options: {
|
||||
mode: { binary: 'Чёрно-белая маска', highlight: 'Цветная подсветка' }
|
||||
}
|
||||
},
|
||||
'show-grayscale-pixels-png': {
|
||||
title: 'Показать серые пиксели PNG',
|
||||
description:
|
||||
'Находит пиксели с почти равными каналами и рисует их маску. Допуск — в единицах канала.',
|
||||
params: { tolerance: 'Допуск по каналам', mode: 'Режим маски', color: 'Цвет подсветки', opacity: 'Непрозрачность подсветки, %' },
|
||||
options: {
|
||||
mode: { binary: 'Чёрно-белая маска', highlight: 'Цветная подсветка' }
|
||||
}
|
||||
},
|
||||
'show-color-pixels-png': {
|
||||
title: 'Показать цветные пиксели PNG',
|
||||
description: 'Находит цветные (не серые) пиксели за пределами допуска и рисует их маску.',
|
||||
params: { tolerance: 'Допуск по каналам', mode: 'Режим маски', color: 'Цвет подсветки', opacity: 'Непрозрачность подсветки, %' },
|
||||
options: {
|
||||
mode: { binary: 'Чёрно-белая маска', highlight: 'Цветная подсветка' }
|
||||
}
|
||||
},
|
||||
'light-pixel-mask-png': {
|
||||
title: 'Маска светлых пикселей PNG',
|
||||
description: 'Выбирает пиксели ярче порога яркости.',
|
||||
params: { threshold: 'Порог яркости, %', mode: 'Режим маски', color: 'Цвет подсветки', opacity: 'Непрозрачность подсветки, %' },
|
||||
options: {
|
||||
mode: { binary: 'Чёрно-белая маска', highlight: 'Цветная подсветка' }
|
||||
}
|
||||
},
|
||||
'dark-pixel-mask-png': {
|
||||
title: 'Маска тёмных пикселей PNG',
|
||||
description: 'Выбирает пиксели темнее порога яркости.',
|
||||
params: { threshold: 'Порог яркости, %', mode: 'Режим маски', color: 'Цвет подсветки', opacity: 'Непрозрачность подсветки, %' },
|
||||
options: {
|
||||
mode: { binary: 'Чёрно-белая маска', highlight: 'Цветная подсветка' }
|
||||
}
|
||||
},
|
||||
'unique-color-mask-png': {
|
||||
title: 'Маска уникальных цветов PNG',
|
||||
description:
|
||||
'Выбирает цвета, которые встречаются не чаще заданного числа раз, — редкие и одиночные пиксели.',
|
||||
params: { rarity: 'Максимум повторов', mode: 'Режим маски', color: 'Цвет подсветки', opacity: 'Непрозрачность подсветки, %' },
|
||||
options: {
|
||||
mode: { binary: 'Чёрно-белая маска', highlight: 'Цветная подсветка' }
|
||||
}
|
||||
},
|
||||
'extract-color-from-png': {
|
||||
title: 'Извлечь цвет из PNG',
|
||||
description:
|
||||
'Оставляет только пиксели, близкие к выбранному цвету, остальное делает прозрачным — обратное «Удалить цвет».',
|
||||
params: { color: 'Какой цвет оставить', tolerance: 'Допуск похожести, %' }
|
||||
},
|
||||
'watermark-tile-png': {
|
||||
title: 'Плитка-водяной знак PNG',
|
||||
description:
|
||||
|
||||
+186
-5
@@ -77,6 +77,13 @@ import {
|
||||
triadicSet,
|
||||
type SortKey
|
||||
} from './core/palette';
|
||||
import {
|
||||
extractByColor,
|
||||
isGrayscaleish,
|
||||
luma01,
|
||||
rarityPredicate,
|
||||
renderPredicateMask
|
||||
} from './core/masks';
|
||||
import { renderSpace, SPACES, type SpaceId } from './core/channels';
|
||||
import { hexToPixels, pixelsToHex } from './core/text';
|
||||
import { clonePixelImage, type PixelImage } from './core/types';
|
||||
@@ -174,6 +181,167 @@ function bool(params: Record<string, unknown>, id: string): boolean {
|
||||
return v;
|
||||
}
|
||||
|
||||
type MaskToolSpec = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
defaultMode: 'binary' | 'highlight';
|
||||
predicate: (
|
||||
img: PixelImage,
|
||||
p: Record<string, unknown>
|
||||
) => (r: number, g: number, b: number, a: number) => boolean;
|
||||
extraParams?: ToolEntry['params'];
|
||||
};
|
||||
|
||||
const MASK_TOOLS: MaskToolSpec[] = [
|
||||
{
|
||||
id: 'show-transparent-png',
|
||||
title: 'Show Transparent Areas PNG',
|
||||
description:
|
||||
'Highlights every transparent or semi-transparent pixel with the chosen color so gaps become obvious.',
|
||||
defaultMode: 'highlight',
|
||||
predicate:
|
||||
(_img, _p) =>
|
||||
(_r, _g, _b, a) =>
|
||||
a < 255
|
||||
},
|
||||
{
|
||||
id: 'show-grayscale-pixels-png',
|
||||
title: 'Show Grayscale Pixels PNG',
|
||||
description:
|
||||
'Finds pixels whose channels are nearly equal and renders them as a mask. Tolerance is in channel units.',
|
||||
defaultMode: 'binary',
|
||||
predicate:
|
||||
(_i, p) =>
|
||||
(r, g, b) =>
|
||||
isGrayscaleish(r, g, b, num(p, 'tolerance')),
|
||||
extraParams: [
|
||||
{
|
||||
id: 'tolerance',
|
||||
label: 'Channel tolerance',
|
||||
type: 'slider',
|
||||
min: 0,
|
||||
max: 64,
|
||||
step: 1,
|
||||
default: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'show-color-pixels-png',
|
||||
title: 'Show Color Pixels PNG',
|
||||
description:
|
||||
'Finds colored (non-gray) pixels beyond the channel tolerance and renders them as a mask.',
|
||||
defaultMode: 'binary',
|
||||
predicate:
|
||||
(_i, p) =>
|
||||
(r, g, b) =>
|
||||
!isGrayscaleish(r, g, b, num(p, 'tolerance')),
|
||||
extraParams: [
|
||||
{
|
||||
id: 'tolerance',
|
||||
label: 'Channel tolerance',
|
||||
type: 'slider',
|
||||
min: 0,
|
||||
max: 64,
|
||||
step: 1,
|
||||
default: 8
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'light-pixel-mask-png',
|
||||
title: 'Light Pixel Mask PNG',
|
||||
description: 'Selects pixels brighter than the luminance threshold.',
|
||||
defaultMode: 'binary',
|
||||
predicate:
|
||||
(_i, p) =>
|
||||
(r, g, b) =>
|
||||
luma01(r, g, b) >= num(p, 'threshold') / 100,
|
||||
extraParams: [
|
||||
{
|
||||
id: 'threshold',
|
||||
label: 'Luminance threshold, %',
|
||||
type: 'slider',
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 70
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'dark-pixel-mask-png',
|
||||
title: 'Dark Pixel Mask PNG',
|
||||
description: 'Selects pixels darker than the luminance threshold.',
|
||||
defaultMode: 'binary',
|
||||
predicate:
|
||||
(_i, p) =>
|
||||
(r, g, b) =>
|
||||
luma01(r, g, b) <= num(p, 'threshold') / 100,
|
||||
extraParams: [
|
||||
{
|
||||
id: 'threshold',
|
||||
label: 'Luminance threshold, %',
|
||||
type: 'slider',
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 30
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'unique-color-mask-png',
|
||||
title: 'Unique Color Mask PNG',
|
||||
description:
|
||||
'Selects colors that occur no more than the given number of times — rare and one-off pixels.',
|
||||
defaultMode: 'binary',
|
||||
predicate: (img, p) => rarityPredicate(img, num(p, 'rarity')),
|
||||
extraParams: [
|
||||
{ id: 'rarity', label: 'Max occurrences', type: 'slider', min: 1, max: 50, step: 1, default: 1 }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
function maskEntries(): ToolEntry[] {
|
||||
return MASK_TOOLS.map((spec) => ({
|
||||
id: spec.id,
|
||||
title: spec.title,
|
||||
description: spec.description,
|
||||
category: 'analyze' as const,
|
||||
params: [
|
||||
...(spec.extraParams ?? []),
|
||||
{
|
||||
id: 'mode',
|
||||
label: 'Mask mode',
|
||||
type: 'select' as const,
|
||||
default: spec.defaultMode,
|
||||
options: [
|
||||
{ value: 'binary', label: 'Black & white mask' },
|
||||
{ value: 'highlight', label: 'Color highlight' }
|
||||
]
|
||||
},
|
||||
{ id: 'color', label: 'Highlight color', type: 'color', default: '#ff00aa' },
|
||||
{
|
||||
id: 'opacity',
|
||||
label: 'Highlight opacity, %',
|
||||
type: 'slider',
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 5,
|
||||
default: 70
|
||||
}
|
||||
],
|
||||
run: (img, p) =>
|
||||
renderPredicateMask(img, spec.predicate(img, p), {
|
||||
mode: str(p, 'mode') === 'highlight' ? 'highlight' : 'binary',
|
||||
color: str(p, 'color'),
|
||||
opacityPercent: num(p, 'opacity')
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
type SpaceEntry = {
|
||||
id: SpaceId;
|
||||
suffix: string;
|
||||
@@ -312,7 +480,8 @@ function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
|
||||
}
|
||||
|
||||
export const TOOLS: ToolEntry[] = [
|
||||
...channelEntries(),
|
||||
...channelEntries(),
|
||||
...maskEntries(),
|
||||
decodeToPng(
|
||||
'jpg-to-png',
|
||||
'Convert JPG to PNG',
|
||||
@@ -910,10 +1079,22 @@ export const TOOLS: ToolEntry[] = [
|
||||
{ id: 'targetColor', label: 'Color to remove', type: 'color', default: '#00ff00' },
|
||||
{ id: 'tolerance', label: 'Similarity threshold, %', type: 'slider', min: 0, max: 100, step: 1, default: 10 }
|
||||
],
|
||||
run: (img, p) => removeColorToAlpha(img, str(p, 'targetColor'), num(p, 'tolerance')),
|
||||
preview: (img, p) => colorMask(img, str(p, 'targetColor'), num(p, 'tolerance'))
|
||||
},
|
||||
{
|
||||
run: (img, p) => removeColorToAlpha(img, str(p, 'targetColor'), num(p, 'tolerance')),
|
||||
preview: (img, p) => colorMask(img, str(p, 'targetColor'), num(p, 'tolerance'))
|
||||
},
|
||||
{
|
||||
id: 'extract-color-from-png',
|
||||
title: 'Extract Color from PNG',
|
||||
description:
|
||||
'Keeps only pixels close to the chosen color and makes everything else transparent — the inverse of Remove Color.',
|
||||
category: 'analyze',
|
||||
params: [
|
||||
{ id: 'color', label: 'Color to keep', type: 'color', default: '#00ff88' },
|
||||
{ id: 'tolerance', label: 'Similarity tolerance, %', type: 'slider', min: 0, max: 50, step: 1, default: 10 }
|
||||
],
|
||||
run: (img, p) => extractByColor(img, str(p, 'color'), num(p, 'tolerance'))
|
||||
},
|
||||
{
|
||||
id: 'png-info',
|
||||
title: 'PNG info',
|
||||
description:
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Layers,
|
||||
Link2,
|
||||
Maximize2,
|
||||
Moon,
|
||||
PaintBucket,
|
||||
Palette,
|
||||
Rainbow,
|
||||
@@ -105,6 +106,13 @@ export const TOOL_ICONS: Record<string, typeof AppWindow> = {
|
||||
'png-to-cmyk': PaintBucket,
|
||||
'png-to-ycbcr': Layers,
|
||||
'png-to-lab': Ruler,
|
||||
'show-transparent-png': Eye,
|
||||
'show-grayscale-pixels-png': Contrast,
|
||||
'show-color-pixels-png': Rainbow,
|
||||
'light-pixel-mask-png': Sun,
|
||||
'dark-pixel-mask-png': Moon,
|
||||
'unique-color-mask-png': Dices,
|
||||
'extract-color-from-png': Focus,
|
||||
'watermark-tile-png': Stamp,
|
||||
'watermark-image-png': FileImage,
|
||||
'color-wheel-png': Rainbow,
|
||||
|
||||
Reference in New Issue
Block a user