feat: add channel tools

This commit is contained in:
2026-08-26 08:38:30 +05:00
parent db02cbd165
commit 5c37eb2e7b
6 changed files with 364 additions and 4 deletions
+8 -2
View File
@@ -52,6 +52,13 @@
- gamma-png — value - gamma-png — value
- tint-png — color, strength - 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 - resize-png — width (0=авто), height (0=авто), keepAspect
@@ -110,9 +117,8 @@
- 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 (три частных случая одного движка)
### Разложение каналов ### Разложение каналов — остаток
- png-to-hsl / hsv / hsi / cmyk / ycbcr / lab — channel (какой компонент показать), режим отображения (серый/окрашенный)
- separate-colors — minShare слоя (MEDIUM, мультифайловый вывод → пока идея) - separate-colors — minShare слоя (MEDIUM, мультифайловый вывод → пока идея)
### Маски по свойствам пикселей ### Маски по свойствам пикселей
+65
View File
@@ -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);
});
});
+128
View File
@@ -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<SpaceId, SpaceDef> = {
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;
}
+66
View File
@@ -547,6 +547,72 @@ export const ru: Dict = {
grayscaleNo: 'Нет — найдены цветные пиксели.' 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': { 'watermark-tile-png': {
title: 'Плитка-водяной знак PNG', title: 'Плитка-водяной знак PNG',
description: description:
+89
View File
@@ -77,6 +77,7 @@ import {
triadicSet, triadicSet,
type SortKey type SortKey
} from './core/palette'; } from './core/palette';
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';
@@ -173,6 +174,93 @@ function bool(params: Record<string, unknown>, id: string): boolean {
return v; 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 greenmagenta / blueyellow 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) { function paletteParams(baseDefault: string) {
return [ return [
{ id: 'baseColor', label: 'Base color', type: 'color' as const, default: baseDefault }, { id: 'baseColor', label: 'Base color', type: 'color' as const, default: baseDefault },
@@ -224,6 +312,7 @@ function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
} }
export const TOOLS: ToolEntry[] = [ export const TOOLS: ToolEntry[] = [
...channelEntries(),
decodeToPng( decodeToPng(
'jpg-to-png', 'jpg-to-png',
'Convert JPG to PNG', 'Convert JPG to PNG',
+6
View File
@@ -99,6 +99,12 @@ export const TOOL_ICONS: Record<string, typeof AppWindow> = {
'add-stroke-png': Square, 'add-stroke-png': Square,
'add-text-png': Type, 'add-text-png': Type,
'date-stamp-png': CalendarDays, '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-tile-png': Stamp,
'watermark-image-png': FileImage, 'watermark-image-png': FileImage,
'color-wheel-png': Rainbow, 'color-wheel-png': Rainbow,