mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 21:46:35 +00:00
feat: add masks core tools
This commit is contained in:
@@ -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
@@ -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, позиция
|
||||||
|
|||||||
@@ -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' }
|
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
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user