feat: add channel tools
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user