feat: add masks core tools

This commit is contained in:
2026-08-26 09:26:26 +05:00
parent 5c37eb2e7b
commit 553601acc1
7 changed files with 467 additions and 21 deletions
+4 -4
View File
@@ -1,4 +1,4 @@
# План: закрытие EASY/MEDIUM-пробелов из сравнения с onlinepngtools # План: закрытие EASY/MEDIUM-пробелов из сравнения с onlinepngtools
> Статус: черновик на ревью > Статус: черновик на ревью
@@ -11,18 +11,18 @@
## Очередь волн ## Очередь волн
### W1. Палитры — 8 инструментов, S/M ### W1. Палитры — ВЫПОЛНЕНА (11 инструментов)
Ядро: RGB↔HSL + гармонии цветового круга. Вывод: свотч-полотно (генератор). Ядро: RGB↔HSL + гармонии цветового круга. Вывод: свотч-полотно (генератор).
Состав: color-wheel, complementary, monochromatic, analogous, triadic, tetradic, similar-shades, sort-colors. Состав: color-wheel, complementary, monochromatic, analogous, triadic, tetradic, similar-shades, sort-colors.
Плюс утилиты смешения тем же ядром: mix-colors, average-color, blend-two, step-between (+4, итого 12). Плюс утилиты смешения тем же ядром: mix-colors, average-color, blend-two, step-between (+4, итого 12).
### W2. Каналы и пространства — 6 инструментов, S ### W2. Каналы и пространства — ВЫПОЛНЕНА (6 инструментов)
Ядро: матрицы преобразования RGB→(HSL/HSV/HSI/CMYK/YCbCr/LAB) + визуализация выбранного компонента серым или окрашенно. Ядро: матрицы преобразования RGB→(HSL/HSV/HSI/CMYK/YCbCr/LAB) + визуализация выбранного компонента серым или окрашенно.
Общий select «компонент» + select «режим отображения». Общий select «компонент» + select «режим отображения».
### W3. Маски по свойствам пикселей — 7 инструментов, S ### W3. Маски по свойствам пикселей — ВЫПОЛНЕНА (7 инструментов)
Ядро: предикат над пикселем → бинарная маска (с инверсией и подсветкой цветом). Ядро: предикат над пикселем → бинарная маска (с инверсией и подсветкой цветом).
Состав: show-transparent, show-grayscale, show-color, light-mask, dark-mask, unique-color-mask, extract-by-color. Состав: show-transparent, show-grayscale, show-color, light-mask, dark-mask, unique-color-mask, extract-by-color.
+14 -12
View File
@@ -86,6 +86,16 @@
- png-info — размеры, альфа, число цветов - png-info — размеры, альфа, число цветов
- png-is-transparent / png-is-grayscale / png-orientation — текстовый вердикт - png-is-transparent / png-is-grayscale / png-orientation — текстовый вердикт
### Маски по свойствам пикселей
- show-transparent-png — color, opacity (подсветка прозрачных/полупрозрачных)
- show-grayscale-pixels-png — tolerance, mode (binary/highlight), highlightColor, highlightOpacity
- show-color-pixels-png — tolerance, mode, highlightColor, highlightOpacity
- light-pixel-mask-png — threshold, mode, highlightColor, highlightOpacity
- dark-pixel-mask-png — threshold, mode, highlightColor, highlightOpacity
- unique-color-mask-png — rarity (макс. повторов), mode, highlightColor, highlightOpacity
- extract-color-from-png — color, tolerance (оставить близкие, остальное прозрачным)
### Генерация ### Генерация
- create-empty-png — width, height, transparent, color - create-empty-png — width, height, transparent, color
@@ -100,10 +110,6 @@
- watermark-tile-png — text, fontSize, color, opacity, angle, stepX, stepY, font, bold - watermark-tile-png — text, fontSize, color, opacity, angle, stepX, stepY, font, bold
- watermark-image-png — вторая картинка-знак (загружается на странице), scale, opacity, position, margin - watermark-image-png — вторая картинка-знак (загружается на странице), scale, opacity, position, margin
---
## 2. Можно добавить — из onlinepngtools
### Палитры и цветовые утилиты ### Палитры и цветовые утилиты
- color-wheel-generator — size, кольца/сектора, показ hex при клике (у нас — статичный свотч-полотно) - color-wheel-generator — size, кольца/сектора, показ hex при клике (у нас — статичный свотч-полотно)
@@ -117,18 +123,14 @@
- mix-colors — colors[], веса? - mix-colors — colors[], веса?
- average-color — colors[]; blend-two — a, b, steps; step-between — a, b, steps (три частных случая одного движка) - average-color — colors[]; blend-two — a, b, steps; step-between — a, b, steps (три частных случая одного движка)
---
## 2. Можно добавить — из onlinepngtools
### Разложение каналов — остаток ### Разложение каналов — остаток
- separate-colors — minShare слоя (MEDIUM, мультифайловый вывод → пока идея) - separate-colors — minShare слоя (MEDIUM, мультифайловый вывод → пока идея)
### Маски по свойствам пикселей
- show-transparent-areas — подсветка цветом, полупрозрачность подсветки
- show-grayscale-pixels / show-color-pixels — маска серых/цветных
- light-pixel-mask / dark-pixel-mask — threshold яркости
- unique-color-mask — порог редкости
- extract-color-from-png — color, tolerance (обратное remove-color: оставить только цвет)
### Фигурные маски ### Фигурные маски
- circle-mask — diameter/fit, позиция - circle-mask — diameter/fit, позиция
+80
View File
@@ -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);
});
});
+114
View File
@@ -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;
};
}
+61
View File
@@ -613,6 +613,67 @@ export const ru: Dict = {
display: { gray: 'Градациями серого', color: 'Пространство как RGB' } 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': { 'watermark-tile-png': {
title: 'Плитка-водяной знак PNG', title: 'Плитка-водяной знак PNG',
description: description:
+186 -5
View File
@@ -77,6 +77,13 @@ import {
triadicSet, triadicSet,
type SortKey type SortKey
} from './core/palette'; } from './core/palette';
import {
extractByColor,
isGrayscaleish,
luma01,
rarityPredicate,
renderPredicateMask
} from './core/masks';
import { renderSpace, SPACES, type SpaceId } from './core/channels'; import { renderSpace, SPACES, type SpaceId } from './core/channels';
import { hexToPixels, pixelsToHex } from './core/text'; import { hexToPixels, pixelsToHex } from './core/text';
import { clonePixelImage, type PixelImage } from './core/types'; import { clonePixelImage, type PixelImage } from './core/types';
@@ -174,6 +181,167 @@ function bool(params: Record<string, unknown>, id: string): boolean {
return v; 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 = { type SpaceEntry = {
id: SpaceId; id: SpaceId;
suffix: string; suffix: string;
@@ -312,7 +480,8 @@ function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
} }
export const TOOLS: ToolEntry[] = [ export const TOOLS: ToolEntry[] = [
...channelEntries(), ...channelEntries(),
...maskEntries(),
decodeToPng( decodeToPng(
'jpg-to-png', 'jpg-to-png',
'Convert 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: 'targetColor', label: 'Color to remove', type: 'color', default: '#00ff00' },
{ id: 'tolerance', label: 'Similarity threshold, %', type: 'slider', min: 0, max: 100, step: 1, default: 10 } { 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')), run: (img, p) => removeColorToAlpha(img, str(p, 'targetColor'), num(p, 'tolerance')),
preview: (img, p) => colorMask(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', id: 'png-info',
title: 'PNG info', title: 'PNG info',
description: description:
+8
View File
@@ -26,6 +26,7 @@ import {
Layers, Layers,
Link2, Link2,
Maximize2, Maximize2,
Moon,
PaintBucket, PaintBucket,
Palette, Palette,
Rainbow, Rainbow,
@@ -105,6 +106,13 @@ export const TOOL_ICONS: Record<string, typeof AppWindow> = {
'png-to-cmyk': PaintBucket, 'png-to-cmyk': PaintBucket,
'png-to-ycbcr': Layers, 'png-to-ycbcr': Layers,
'png-to-lab': Ruler, '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-tile-png': Stamp,
'watermark-image-png': FileImage, 'watermark-image-png': FileImage,
'color-wheel-png': Rainbow, 'color-wheel-png': Rainbow,