diff --git a/docs/plan-gap-waves.md b/docs/plan-gap-waves.md index 8a5eb98..ed5450b 100644 --- a/docs/plan-gap-waves.md +++ b/docs/plan-gap-waves.md @@ -1,4 +1,4 @@ -# План: закрытие EASY/MEDIUM-пробелов из сравнения с onlinepngtools +# План: закрытие EASY/MEDIUM-пробелов из сравнения с onlinepngtools > Статус: черновик на ревью @@ -11,18 +11,18 @@ ## Очередь волн -### W1. Палитры — 8 инструментов, S/M +### W1. Палитры — ВЫПОЛНЕНА (11 инструментов) Ядро: RGB↔HSL + гармонии цветового круга. Вывод: свотч-полотно (генератор). Состав: color-wheel, complementary, monochromatic, analogous, triadic, tetradic, similar-shades, sort-colors. Плюс утилиты смешения тем же ядром: mix-colors, average-color, blend-two, step-between (+4, итого 12). -### W2. Каналы и пространства — 6 инструментов, S +### W2. Каналы и пространства — ВЫПОЛНЕНА (6 инструментов) Ядро: матрицы преобразования RGB→(HSL/HSV/HSI/CMYK/YCbCr/LAB) + визуализация выбранного компонента серым или окрашенно. Общий select «компонент» + select «режим отображения». -### W3. Маски по свойствам пикселей — 7 инструментов, S +### W3. Маски по свойствам пикселей — ВЫПОЛНЕНА (7 инструментов) Ядро: предикат над пикселем → бинарная маска (с инверсией и подсветкой цветом). Состав: show-transparent, show-grayscale, show-color, light-mask, dark-mask, unique-color-mask, extract-by-color. diff --git a/docs/tools-map.md b/docs/tools-map.md index 4144187..aef4eaf 100644 --- a/docs/tools-map.md +++ b/docs/tools-map.md @@ -86,6 +86,16 @@ - png-info — размеры, альфа, число цветов - 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 @@ -100,10 +110,6 @@ - watermark-tile-png — text, fontSize, color, opacity, angle, stepX, stepY, font, bold - watermark-image-png — вторая картинка-знак (загружается на странице), scale, opacity, position, margin ---- - -## 2. Можно добавить — из onlinepngtools - ### Палитры и цветовые утилиты - color-wheel-generator — size, кольца/сектора, показ hex при клике (у нас — статичный свотч-полотно) @@ -117,18 +123,14 @@ - mix-colors — colors[], веса? - average-color — colors[]; blend-two — a, b, steps; step-between — a, b, steps (три частных случая одного движка) +--- + +## 2. Можно добавить — из onlinepngtools + ### Разложение каналов — остаток - 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, позиция diff --git a/web/src/lib/core/masks.test.ts b/web/src/lib/core/masks.test.ts new file mode 100644 index 0000000..1fc40c6 --- /dev/null +++ b/web/src/lib/core/masks.test.ts @@ -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); + }); +}); diff --git a/web/src/lib/core/masks.ts b/web/src/lib/core/masks.ts new file mode 100644 index 0000000..fbac460 --- /dev/null +++ b/web/src/lib/core/masks.ts @@ -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(); + 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; + }; +} + diff --git a/web/src/lib/i18n/ru.ts b/web/src/lib/i18n/ru.ts index 39fd54b..113abc1 100644 --- a/web/src/lib/i18n/ru.ts +++ b/web/src/lib/i18n/ru.ts @@ -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: diff --git a/web/src/lib/registry.ts b/web/src/lib/registry.ts index 521250b..1706c9e 100644 --- a/web/src/lib/registry.ts +++ b/web/src/lib/registry.ts @@ -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, id: string): boolean { return v; } +type MaskToolSpec = { + id: string; + title: string; + description: string; + defaultMode: 'binary' | 'highlight'; + predicate: ( + img: PixelImage, + p: Record + ) => (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: diff --git a/web/src/lib/tools/tool-icons.ts b/web/src/lib/tools/tool-icons.ts index a064b08..5834e3f 100644 --- a/web/src/lib/tools/tool-icons.ts +++ b/web/src/lib/tools/tool-icons.ts @@ -26,6 +26,7 @@ import { Layers, Link2, Maximize2, + Moon, PaintBucket, Palette, Rainbow, @@ -105,6 +106,13 @@ export const TOOL_ICONS: Record = { '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,