From 5c37eb2e7b6bc41a219550e8be237697266ce5a1 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Wed, 26 Aug 2026 08:38:30 +0500 Subject: [PATCH] feat: add channel tools --- docs/tools-map.md | 10 ++- web/src/lib/core/channels.test.ts | 65 +++++++++++++++ web/src/lib/core/channels.ts | 128 ++++++++++++++++++++++++++++++ web/src/lib/i18n/ru.ts | 66 +++++++++++++++ web/src/lib/registry.ts | 93 +++++++++++++++++++++- web/src/lib/tools/tool-icons.ts | 6 ++ 6 files changed, 364 insertions(+), 4 deletions(-) create mode 100644 web/src/lib/core/channels.test.ts create mode 100644 web/src/lib/core/channels.ts diff --git a/docs/tools-map.md b/docs/tools-map.md index 50a0ef6..4144187 100644 --- a/docs/tools-map.md +++ b/docs/tools-map.md @@ -52,6 +52,13 @@ - gamma-png — value - tint-png — color, strength +### Разложение каналов + +- png-to-hsl / png-to-hsv / png-to-hsi — component (h/s/l и т.п.), display (gray | space-as-rgb) +- png-to-cmyk — component (c/m/y/k), display +- png-to-ycbcr — component (y/cb/cr), display +- png-to-lab — component (l/a/b), display + ### Геометрия - resize-png — width (0=авто), height (0=авто), keepAspect @@ -110,9 +117,8 @@ - mix-colors — colors[], веса? - average-color — colors[]; blend-two — a, b, steps; step-between — a, b, steps (три частных случая одного движка) -### Разложение каналов +### Разложение каналов — остаток -- png-to-hsl / hsv / hsi / cmyk / ycbcr / lab — channel (какой компонент показать), режим отображения (серый/окрашенный) - separate-colors — minShare слоя (MEDIUM, мультифайловый вывод → пока идея) ### Маски по свойствам пикселей diff --git a/web/src/lib/core/channels.test.ts b/web/src/lib/core/channels.test.ts new file mode 100644 index 0000000..0a4f0e0 --- /dev/null +++ b/web/src/lib/core/channels.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { hexToRgb } from './palette'; +import { renderSpace, SPACES } from './channels'; +import { makeImage } from './test-helpers'; + +describe('преобразования пространств', () => { + it('hsl красного: h=0, s=1, l=0.5', () => { + const [h, s, l] = SPACES.hsl.convert(hexToRgb('#ff0000')); + expect(h).toBeCloseTo(0); + expect(s).toBeCloseTo(1); + expect(l).toBeCloseTo(0.5); + }); + + it('hsv белого: v=1, чёрного: v=0', () => { + expect(SPACES.hsv.convert({ r: 255, g: 255, b: 255 })[2]).toBeCloseTo(1); + expect(SPACES.hsv.convert({ r: 0, g: 0, b: 0 })[2]).toBe(0); + }); + + it('hsi серого: s=0', () => { + const [, s] = SPACES.hsi.convert({ r: 128, g: 128, b: 128 }); + expect(s).toBeCloseTo(0); + }); + + it('cmyk: белый — 0,0,0,0; чёрный — 0,0,0,1', () => { + const w = SPACES.cmyk.convert({ r: 255, g: 255, b: 255 }); + const b = SPACES.cmyk.convert({ r: 0, g: 0, b: 0 }); + expect(w.every((v) => Math.abs(v) < 1e-9)).toBe(true); + expect(b[3]).toBeCloseTo(1); + }); + + it('ycbcr белого: y≈1, cb≈cr≈0.5', () => { + const [y, cb, cr] = SPACES.ycbcr.convert({ r: 255, g: 255, b: 255 }); + expect(y).toBeCloseTo(1); + expect(cb).toBeCloseTo(0.5, 2); + expect(cr).toBeCloseTo(0.5, 2); + }); + + it('lab белого: l≈1, a≈b≈0.5 (центр)', () => { + const [l, a, bb] = SPACES.lab.convert({ r: 255, g: 255, b: 255 }); + expect(l).toBeCloseTo(1, 2); + expect(a).toBeCloseTo(0.5, 2); + expect(bb).toBeCloseTo(0.5, 2); + }); +}); + +describe('renderSpace', () => { + const red = makeImage(1, 1, [[255, 0, 0, 255]]); + + it('gray-режим: компонент y (жёлтый) чистого красного = белый', () => { + const out = renderSpace(red, 'cmyk', 'y', 'gray'); + expect([...out.data]).toEqual([255, 255, 255, 255]); + }); + + it('color-режим hsl красного: каналы = тон/насыщенность/светлота', () => { + const out = renderSpace(red, 'hsl', 's', 'color'); + expect(out.data[0]).toBe(0); + expect(out.data[1]).toBe(255); + expect(out.data[2]).toBeGreaterThanOrEqual(127); + }); + + it('неизвестное пространство или компонент дают прозрачную заглушку', () => { + const junk = makeImage(1, 1, [[10, 20, 30, 255]]); + expect(renderSpace(junk, 'cmyk' as never, 'zz' as never, 'gray').data[3]).toBe(0); + }); +}); diff --git a/web/src/lib/core/channels.ts b/web/src/lib/core/channels.ts new file mode 100644 index 0000000..4c52ed2 --- /dev/null +++ b/web/src/lib/core/channels.ts @@ -0,0 +1,128 @@ +import type { PixelImage } from './types'; +import { createPixelImage } from './types'; +import { rgbToHsl } from './palette'; +import type { Rgb } from './palette'; + +/** Все компоненты нормализованы в 0..1 в порядке объявления. */ +export type SpaceComponents = number[]; + +export type SpaceId = 'hsl' | 'hsv' | 'hsi' | 'cmyk' | 'ycbcr' | 'lab'; + +function hueOf({ r, g, b }: Rgb): number { + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + if (max === min) return 0; + const d = max - min; + let h: number; + if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60; + else if (max === g) h = ((b - r) / d + 2) * 60; + else h = ((r - g) / d + 4) * 60; + return h; +} + +function rgbToHsv({ r, g, b }: Rgb): [number, number, number] { + r /= 255; + g /= 255; + b /= 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + return [hueOf({ r, g, b }), max === 0 ? 0 : (max - min) / max, max]; +} + +function rgbToHsi({ r, g, b }: Rgb): [number, number, number] { + r /= 255; + g /= 255; + b /= 255; + const sum = r + g + b; + const intensity = sum / 3; + if (sum === 0) return [0, 0, 0]; + const min = Math.min(r, g, b); + const saturation = 1 - min / intensity; + return [hueOf({ r, g, b }), saturation, intensity]; +} + +function rgbToCmyk({ r, g, b }: Rgb): [number, number, number, number] { + const rn = r / 255; + const gn = g / 255; + const bn = b / 255; + const k = 1 - Math.max(rn, gn, bn); + if (k === 1) return [0, 0, 0, 1]; + return [(1 - rn - k) / (1 - k), (1 - gn - k) / (1 - k), (1 - bn - k) / (1 - k), k]; +} + +function rgbToYcbcr({ r, g, b }: Rgb): [number, number, number] { + const y = 0.299 * r + 0.587 * g + 0.114 * b; + const cb = 128 - 0.168736 * r - 0.331264 * g + 0.5 * b; + const cr = 128 + 0.5 * r - 0.418688 * g - 0.081312 * b; + return [y / 255, cb / 255, cr / 255]; +} + +function srgbTransfer(v: number): number { + return v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055; +} + +function rgbToLab({ r, g, b }: Rgb): [number, number, number] { + const lin = [r, g, b].map((v) => srgbTransfer(v / 255)); + const x = (lin[0] * 0.4124 + lin[1] * 0.3576 + lin[2] * 0.1805) / 0.95047; + const y = lin[0] * 0.2126 + lin[1] * 0.7152 + lin[2] * 0.0722; + const z = (lin[0] * 0.0193 + lin[1] * 0.1192 + lin[2] * 0.9505) / 1.08883; + const f = (t: number) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116); + const fx = f(x); + const fy = f(y); + const fz = f(z); + // L нормирован 0..1; a/b центрированы на 0.5 с размахом ±0.5 + return [(116 * fy - 16) / 100, (500 * (fx - fy)) / 250 + 0.5, (200 * (fy - fz)) / 250 + 0.5]; +} + +type SpaceDef = { components: string[]; convert: (rgb: Rgb) => SpaceComponents }; + +export const SPACES: Record = { + hsl: { + components: ['h', 's', 'l'], + convert: ({ r, g, b }) => { + const { h, s, l } = rgbToHsl({ r, g, b }); + return [h / 360, s, l]; + } + }, + hsv: { components: ['h', 's', 'v'], convert: rgbToHsv }, + hsi: { components: ['h', 's', 'i'], convert: rgbToHsi }, + cmyk: { components: ['c', 'm', 'y', 'k'], convert: rgbToCmyk }, + ycbcr: { components: ['y', 'cb', 'cr'], convert: rgbToYcbcr }, + lab: { components: ['l', 'a', 'b'], convert: rgbToLab } +}; + +export type ChannelDisplay = 'gray' | 'color'; + +/** + * Визуализация выбранного пространства: каждый компонент пространства + * попадает в свой канал результата (mode 'color') или выбранный компонент + * рисуется градациями серого (mode 'gray'). + */ +export function renderSpace( + img: PixelImage, + space: SpaceId, + component: string, + display: ChannelDisplay +): PixelImage { + const def = SPACES[space]; + if (!def) return createPixelImage(img.width, img.height); + const idx = def.components.indexOf(component); + if (idx < 0) return createPixelImage(img.width, img.height); + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < img.data.length; i += 4) { + const comps = def.convert({ r: img.data[i], g: img.data[i + 1], b: img.data[i + 2] }); + const di = i; + out.data[di + 3] = img.data[i + 3]; + if (display === 'gray') { + const v = comps[idx] * 255; + out.data[di] = v; + out.data[di + 1] = v; + out.data[di + 2] = v; + } else { + out.data[di] = comps[0] * 255; + out.data[di + 1] = (comps[1] ?? 0) * 255; + out.data[di + 2] = (comps[2] ?? 0) * 255; + } + } + return out; +} diff --git a/web/src/lib/i18n/ru.ts b/web/src/lib/i18n/ru.ts index f24e1b7..39fd54b 100644 --- a/web/src/lib/i18n/ru.ts +++ b/web/src/lib/i18n/ru.ts @@ -547,6 +547,72 @@ export const ru: Dict = { grayscaleNo: 'Нет — найдены цветные пиксели.' } }, + 'png-to-hsl': { + title: 'Разложить PNG в HSL', + description: 'Раскладывает изображение на компоненты Тон, Насыщенность и Светлота.', + params: { component: 'Компонент', display: 'Режим показа' }, + options: { + component: { h: 'Тон (H)', s: 'Насыщенность (S)', l: 'Светлота (L)' }, + display: { gray: 'Градациями серого', color: 'Пространство как RGB' } + } + }, + 'png-to-hsv': { + title: 'Разложить PNG в HSV', + description: 'Раскладывает изображение на Тон, Насыщенность и Яркость (Value).', + params: { component: 'Компонент', display: 'Режим показа' }, + options: { + component: { h: 'Тон (H)', s: 'Насыщенность (S)', v: 'Яркость (V)' }, + display: { gray: 'Градациями серого', color: 'Пространство как RGB' } + } + }, + 'png-to-hsi': { + title: 'Разложить PNG в HSI', + description: 'Раскладывает изображение на Тон, Насыщенность и Интенсивность.', + params: { component: 'Компонент', display: 'Режим показа' }, + options: { + component: { h: 'Тон (H)', s: 'Насыщенность (S)', i: 'Интенсивность (I)' }, + display: { gray: 'Градациями серого', color: 'Пространство как RGB' } + } + }, + 'png-to-cmyk': { + title: 'PNG в CMYK-цвета', + description: + 'Раскладывает изображение на печатные компоненты: Голубой, Пурпурный, Жёлтый и Чёрный (Key).', + params: { component: 'Компонент', display: 'Режим показа' }, + options: { + component: { + c: 'Голубой (C)', + m: 'Пурпурный (M)', + y: 'Жёлтый (Y)', + k: 'Чёрный (K)' + }, + display: { gray: 'Градациями серого', color: 'Пространство как RGB' } + } + }, + 'png-to-ycbcr': { + title: 'PNG в YCbCr-цвета', + description: + 'Раскладывает изображение на Яркость (Y) и цветоразностные компоненты Cb / Cr.', + params: { component: 'Компонент', display: 'Режим показа' }, + options: { + component: { y: 'Яркость (Y)', cb: 'Синий-разностный (Cb)', cr: 'Красный-разностный (Cr)' }, + display: { gray: 'Градациями серого', color: 'Пространство как RGB' } + } + }, + 'png-to-lab': { + title: 'PNG в LAB-цвета', + description: + 'Раскладывает изображение на perceptual-компоненты: Светлость, зелёный–пурпурный и синий–жёлтый.', + params: { component: 'Компонент', display: 'Режим показа' }, + options: { + component: { + l: 'Светлость (L)', + a: 'Зелёный–пурпурный (a)', + b: 'Синий–жёлтый (b)' + }, + display: { gray: 'Градациями серого', color: 'Пространство как RGB' } + } + }, 'watermark-tile-png': { title: 'Плитка-водяной знак PNG', description: diff --git a/web/src/lib/registry.ts b/web/src/lib/registry.ts index e6068b5..521250b 100644 --- a/web/src/lib/registry.ts +++ b/web/src/lib/registry.ts @@ -76,7 +76,8 @@ import { tetradicSet, triadicSet, type SortKey -} from './core/palette'; +} from './core/palette'; +import { renderSpace, SPACES, type SpaceId } from './core/channels'; import { hexToPixels, pixelsToHex } from './core/text'; import { clonePixelImage, type PixelImage } from './core/types'; @@ -173,6 +174,93 @@ function bool(params: Record, id: string): boolean { return v; } +type SpaceEntry = { + id: SpaceId; + suffix: string; + title: string; + description: string; +}; + +const CHANNEL_SPACES: SpaceEntry[] = [ + { + id: 'hsl', + suffix: 'hsl', + title: 'Split PNG into HSL', + description: 'Decomposes the image into Hue, Saturation and Lightness components.' + }, + { + id: 'hsv', + suffix: 'hsv', + title: 'Split PNG into HSV', + description: 'Decomposes the image into Hue, Saturation and Value (brightness) components.' + }, + { + id: 'hsi', + suffix: 'hsi', + title: 'Split PNG into HSI', + description: 'Decomposes the image into Hue, Saturation and Intensity components.' + }, + { + id: 'cmyk', + suffix: 'cmyk', + title: 'Convert PNG to CMYK Colors', + description: + 'Decomposes the image into print-style Cyan, Magenta, Yellow and Key (black) components.' + }, + { + id: 'ycbcr', + suffix: 'ycbcr', + title: 'Convert PNG to YCbCr Colors', + description: + 'Decomposes the image into Luma (Y) and Blue-difference / Red-difference chroma components.' + }, + { + id: 'lab', + suffix: 'lab', + title: 'Convert PNG to LAB Colors', + description: + 'Decomposes the image into perceptual Lightness and green–magenta / blue–yellow opponents.' + } +]; + +function channelEntries(): ToolEntry[] { + return CHANNEL_SPACES.map((space) => { + const components = SPACES[space.id].components; + return { + id: `png-to-${space.suffix}`, + title: space.title, + description: space.description, + category: 'color' as const, + params: [ + { + id: 'component', + label: 'Component', + type: 'select' as const, + default: components[0], + options: components.map((c) => ({ value: c, label: c.toUpperCase() })) + }, + { + id: 'display', + label: 'Display mode', + type: 'select' as const, + default: 'gray', + options: [ + { value: 'gray', label: 'Grayscale' }, + { value: 'color', label: 'Space as RGB' } + ] + } + ], + run: (img, p) => + renderSpace( + img, + space.id, + str(p, 'component'), + str(p, 'display') === 'color' ? 'color' : 'gray' + ) + } satisfies ToolEntry; + }); +} + function paletteParams(baseDefault: string) { return [ { id: 'baseColor', label: 'Base color', type: 'color' as const, default: baseDefault }, @@ -223,7 +311,8 @@ function hexToRgba(hex: string, alpha = 255): [number, number, number, number] { ]; } -export const TOOLS: ToolEntry[] = [ +export const TOOLS: ToolEntry[] = [ + ...channelEntries(), decodeToPng( 'jpg-to-png', 'Convert JPG to PNG', diff --git a/web/src/lib/tools/tool-icons.ts b/web/src/lib/tools/tool-icons.ts index 11f576e..a064b08 100644 --- a/web/src/lib/tools/tool-icons.ts +++ b/web/src/lib/tools/tool-icons.ts @@ -99,6 +99,12 @@ export const TOOL_ICONS: Record = { 'add-stroke-png': Square, 'add-text-png': Type, 'date-stamp-png': CalendarDays, + 'png-to-hsl': Blend, + 'png-to-hsv': Droplet, + 'png-to-hsi': Focus, + 'png-to-cmyk': PaintBucket, + 'png-to-ycbcr': Layers, + 'png-to-lab': Ruler, 'watermark-tile-png': Stamp, 'watermark-image-png': FileImage, 'color-wheel-png': Rainbow,