feat: add quantize tools

This commit is contained in:
2026-08-26 17:31:24 +05:00
parent 2b67414e79
commit 501d2dda4e
7 changed files with 421 additions and 5 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ text-to-png (domText без входной картинки), emoji-to-png, plac
color-spectrum, colorful-random (seed), draw-grid. color-spectrum, colorful-random (seed), draw-grid.
multi-color-gradient — если успеем новый тип параметра «список цветов», иначе перенос. multi-color-gradient — если успеем новый тип параметра «список цветов», иначе перенос.
### W8. Цветовые MEDIUM — 4 инструмента, L ### W8. Цветовые MEDIUM — ВЫПОЛНЕНА (4 инструмента; median-cut + FloydSteinberg/Bayer, custom-palette через text-параметр)
Ядро: квантование (median-cut или k-means). Ядро: квантование (median-cut или k-means).
Состав: quantize (k), decrease-color-count (=quantize с пресетами), custom-palette (маппинг на список цветов — нужен тип параметра «список»), dithering (FloydSteinberg/Bayer поверх квантования). Состав: quantize (k), decrease-color-count (=quantize с пресетами), custom-palette (маппинг на список цветов — нужен тип параметра «список»), dithering (FloydSteinberg/Bayer поверх квантования).
+8 -1
View File
@@ -64,6 +64,13 @@
- gamma-png — value - gamma-png — value
- tint-png — color, strength - tint-png — color, strength
### Квантование и палитры
- quantize-png — colors (k, median-cut)
- decrease-color-count-png — maxColors (пресеты 2…256)
- custom-palette-png — colors (hex через запятую, ближайший цвет)
- dithering-png — colors (k), pattern (FloydSteinberg / Bayer 4×4)
### Разложение каналов ### Разложение каналов
- png-to-hsl / png-to-hsv / png-to-hsi — component (h/s/l и т.п.), display (gray | space-as-rgb) - png-to-hsl / png-to-hsv / png-to-hsi — component (h/s/l и т.п.), display (gray | space-as-rgb)
@@ -178,7 +185,7 @@
- reduce-file-size — целевой размер KB (итеративный поиск) - reduce-file-size — целевой размер KB (итеративный поиск)
- optimize-png — пресеты - optimize-png — пресеты
- change-quality — честная семантика для lossless (см. план-гапы §риск) - change-quality — честная семантика для lossless (см. план-гапы §риск)
- low-quality-png — частично покрыт jpeg-artifacts; остаток = сильный quantize - low-quality-png — частично покрыт jpeg-artifacts + quantize
### Генераторы — остаток ### Генераторы — остаток
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { ditherImage, mapToNearest, medianCutPalette, quantizeImage } from './quantize';
import { makeImage } from './test-helpers';
describe('medianCutPalette', () => {
it('два явных кластера при k=2 дают сами цвета', () => {
const img = makeImage(4, 1, [
[0, 0, 0, 255],
[0, 0, 0, 255],
[255, 255, 255, 255],
[255, 255, 255, 255]
]);
const palette = medianCutPalette(img, 2);
expect(palette).toHaveLength(2);
const hexes = palette.map((c) => `${c.r},${c.g},${c.b}`).sort();
expect(hexes).toEqual(['0,0,0', '255,255,255']);
});
it('пустое изображение даёт чёрную заглушку', () => {
const empty = makeImage(2, 2, new Array(4).fill([0, 0, 0, 0]));
expect(medianCutPalette(empty, 4)).toHaveLength(1);
});
});
describe('quantizeImage / ditherImage', () => {
it('квантование укладывает пиксели в палитру, прозрачность сохраняется', () => {
const img = makeImage(3, 1, [
[10, 10, 10, 255],
[250, 250, 250, 255],
[0, 0, 0, 0]
]);
const { image, palette } = quantizeImage(img, 2);
expect(palette.length).toBeLessThanOrEqual(2);
expect(image.data[(2) * 4 + 3]).toBe(0); // прозрачный остался
for (let x = 0; x < 2; x++) {
const i = x * 4;
const matched = palette.some((hex) => {
const c = parseInt(hex.slice(1, 3), 16);
return Math.abs(image.data[i] - c) <= 1;
});
expect(matched).toBe(true);
}
});
it('floyd-steinberg расщепляет серый 50% на чёрное и белое', () => {
const gray = makeImage(16, 16, new Array(256).fill([128, 128, 128, 255]));
const out = ditherImage(gray, 2, 'floyd-steinberg', ['#000000', '#ffffff']);
let hasDark = false;
let hasLight = false;
for (let i = 0; i < out.data.length; i += 4) {
if (out.data[i] < 60) hasDark = true;
if (out.data[i] > 195) hasLight = true;
}
expect(hasDark).toBe(true);
expect(hasLight).toBe(true);
});
it('bayer детерминирован', () => {
const gray = makeImage(8, 8, new Array(64).fill([128, 128, 128, 255]));
const a = ditherImage(gray, 2, 'bayer');
const b = ditherImage(gray, 2, 'bayer');
expect([...a.data]).toEqual([...b.data]);
});
it('полностью прозрачное изображение не падает', () => {
const empty = makeImage(2, 2, new Array(4).fill([0, 0, 0, 0]));
const out = ditherImage(empty, 4, 'bayer');
expect(out.data[3]).toBe(0);
});
});
describe('mapToNearest', () => {
it('маппинг на ближайший из списка', () => {
const img = makeImage(2, 1, [
[10, 10, 10, 255],
[240, 240, 240, 255]
]);
const out = mapToNearest(img, ['#000000', '#ffffff']);
expect(out.data[0]).toBe(0);
expect(out.data[4]).toBe(255);
});
});
+210
View File
@@ -0,0 +1,210 @@
import type { PixelImage } from './types';
import { createPixelImage } from './types';
import { rgbToHex } from './palette';
import { hexToRgb } from './palette';
type Rgb = { r: number; g: number; b: number };
function dist2(a: Rgb, b: Rgb): number {
const dr = a.r - b.r;
const dg = a.g - b.g;
const db = a.b - b.b;
return dr * dr + dg * dg + db * db;
}
function nearestIndex(palette: Rgb[], r: number, g: number, b: number): number {
let best = 0;
let bestDist = Infinity;
for (let i = 0; i < palette.length; i++) {
const d = dist2(palette[i], { r, g, b });
if (d < bestDist) {
bestDist = d;
best = i;
}
}
return best;
}
/** Median-cut: делит корзину с наибольшим разбросом по самому широкому каналу до k корзин. */
export function medianCutPalette(img: PixelImage, k: number): Rgb[] {
const maxColors = Math.max(2, Math.min(64, Math.round(k)));
const pixels: Rgb[] = [];
const total = img.width * img.height;
const step = Math.max(1, Math.floor(total / 32000));
for (let p = 0; p < total; p += step) {
const i = p * 4;
if (img.data[i + 3] === 0) continue;
pixels.push({ r: img.data[i], g: img.data[i + 1], b: img.data[i + 2] });
}
if (pixels.length === 0) return [{ r: 0, g: 0, b: 0 }];
type Bucket = { px: Rgb[]; min: Rgb; max: Rgb };
const makeBucket = (list: Rgb[]): Bucket => {
const min = { r: 255, g: 255, b: 255 };
const max = { r: 0, g: 0, b: 0 };
for (const c of list) {
if (c.r < min.r) min.r = c.r;
if (c.g < min.g) min.g = c.g;
if (c.b < min.b) min.b = c.b;
if (c.r > max.r) max.r = c.r;
if (c.g > max.g) max.g = c.g;
if (c.b > max.b) max.b = c.b;
}
return { px: list, min, max };
};
let buckets: Bucket[] = [makeBucket(pixels)];
while (buckets.length < maxColors) {
let targetIdx = -1;
let targetScore = -1;
buckets.forEach((bucket, idx) => {
if (bucket.px.length < 2) return;
const score =
(bucket.max.r - bucket.min.r) *
(bucket.max.g - bucket.min.g) *
(bucket.max.b - bucket.min.b) *
bucket.px.length;
if (score > targetScore) {
targetScore = score;
targetIdx = idx;
}
});
if (targetIdx < 0 || targetScore <= 0) break;
const bucket = buckets[targetIdx];
const ranges = [
{ ch: 'r' as const, range: bucket.max.r - bucket.min.r },
{ ch: 'g' as const, range: bucket.max.g - bucket.min.g },
{ ch: 'b' as const, range: bucket.max.b - bucket.min.b }
].sort((a, b) => b.range - a.range);
const widest = ranges[0].ch;
bucket.px.sort((a, b) => a[widest] - b[widest]);
const mid = Math.floor(bucket.px.length / 2);
buckets = [
...buckets.slice(0, targetIdx),
makeBucket(bucket.px.slice(0, mid)),
makeBucket(bucket.px.slice(mid)),
...buckets.slice(targetIdx + 1)
];
}
return buckets
.filter((b) => b.px.length > 0)
.map((b) => ({
r: Math.round(b.px.reduce((s, c) => s + c.r, 0) / b.px.length),
g: Math.round(b.px.reduce((s, c) => s + c.g, 0) / b.px.length),
b: Math.round(b.px.reduce((s, c) => s + c.b, 0) / b.px.length)
}));
}
export interface QuantizeResult {
image: PixelImage;
palette: string[];
}
/** Приводит изображение к k цветам: median-cut + ближайший цвет палитры. Прозрачные пиксели не трогаются. */
export function quantizeImage(img: PixelImage, k: number): QuantizeResult {
const palette = medianCutPalette(img, k);
const out = createPixelImage(img.width, img.height);
for (let i = 0; i < img.data.length; i += 4) {
out.data[i + 3] = img.data[i + 3];
if (img.data[i + 3] === 0) continue;
const chosen = nearestIndex(palette, img.data[i], img.data[i + 1], img.data[i + 2]);
out.data[i] = palette[chosen].r;
out.data[i + 1] = palette[chosen].g;
out.data[i + 2] = palette[chosen].b;
}
return { image: out, palette: palette.map(rgbToHex) };
}
/** Маппинг каждого пикселя на ближайший цвет пользовательского списка. */
export function mapToNearest(img: PixelImage, paletteHexes: string[]): PixelImage {
const palette = paletteHexes.map(hexToRgb);
const out = createPixelImage(img.width, img.height);
for (let i = 0; i < img.data.length; i += 4) {
out.data[i + 3] = img.data[i + 3];
if (img.data[i + 3] === 0) continue;
const chosen = nearestIndex(palette, img.data[i], img.data[i + 1], img.data[i + 2]);
out.data[i] = palette[chosen].r;
out.data[i + 1] = palette[chosen].g;
out.data[i + 2] = palette[chosen].b;
}
return out;
}
const BAYER_4 = [
[0, 8, 2, 10],
[12, 4, 14, 6],
[3, 11, 1, 9],
[15, 7, 13, 5]
];
export type DitherPattern = 'floyd-steinberg' | 'bayer';
/**
* Дизеринг к палитре из k цветов (median-cut) или к явно заданному списку hex.
* floyd-steinberg — распространение ошибки; bayer — упорядоченный 4×4.
*/
export function ditherImage(
img: PixelImage,
k: number,
pattern: DitherPattern,
forcedPaletteHexes?: string[]
): PixelImage {
const palette = forcedPaletteHexes
? forcedPaletteHexes.map(hexToRgb)
: medianCutPalette(img, k);
const total = img.width * img.height;
const buf = new Float32Array(total * 3);
for (let p = 0; p < total; p++) {
buf[p * 3] = img.data[p * 4];
buf[p * 3 + 1] = img.data[p * 4 + 1];
buf[p * 3 + 2] = img.data[p * 4 + 2];
}
const spread = 255 / Math.cbrt(Math.max(2, palette.length));
const out = createPixelImage(img.width, img.height);
for (let y = 0; y < img.height; y++) {
for (let x = 0; x < img.width; x++) {
const p = y * img.width + x;
const di = p * 4;
const alpha = img.data[di + 3];
out.data[di + 3] = alpha;
if (alpha === 0) continue;
let r = buf[p * 3];
let g = buf[p * 3 + 1];
let b = buf[p * 3 + 2];
if (pattern === 'bayer') {
const offset = ((BAYER_4[y % 4][x % 4] + 0.5) / 16 - 0.5) * spread;
r += offset;
g += offset;
b += offset;
}
const chosen = nearestIndex(palette, r, g, b);
out.data[di] = palette[chosen].r;
out.data[di + 1] = palette[chosen].g;
out.data[di + 2] = palette[chosen].b;
if (pattern !== 'floyd-steinberg') continue;
const er = r - palette[chosen].r;
const eg = g - palette[chosen].g;
const eb = b - palette[chosen].b;
const push = (nx: number, ny: number, factor: number) => {
if (nx < 0 || ny < 0 || nx >= img.width || ny >= img.height) return;
const np = ny * img.width + nx;
buf[np * 3] += er * factor;
buf[np * 3 + 1] += eg * factor;
buf[np * 3 + 2] += eb * factor;
};
push(x + 1, y, 7 / 16);
push(x - 1, y + 1, 3 / 16);
push(x, y + 1, 5 / 16);
push(x + 1, y + 1, 1 / 16);
}
}
return out;
}
+29
View File
@@ -853,6 +853,35 @@ export const ru: Dict = {
transparentBg: 'Прозрачный фон' transparentBg: 'Прозрачный фон'
} }
}, },
'quantize-png': {
title: 'Квантовать PNG',
description:
'Уменьшает изображение до k цветов через median-cut палитру. Прозрачные пиксели сохраняются.',
params: { colors: 'Цветов (k)' }
},
'decrease-color-count-png': {
title: 'Уменьшить число цветов PNG',
description:
'Тот же движок median-cut с фиксированными пресетами степеней двойки — быстрый спуск до 2–256 цветов.',
params: { maxColors: 'Максимум цветов' },
options: {
maxColors: { '2': '2', '4': '4', '8': '8', '16': '16', '32': '32', '64': '64', '128': '128', '256': '256' }
}
},
'custom-palette-png': {
title: 'Своя палитра PNG',
description: 'Сопоставляет каждый пиксель с ближайшим цветом из вашего списка hex через запятую.',
params: { colors: 'Палитра (hex через запятую)' }
},
'dithering-png': {
title: 'Дизеринг PNG',
description:
'Применяет распространение ошибки Флойда–Стейнберга или упорядоченный Байер при сведении к k цветам.',
params: { colors: 'Цветов (k)', pattern: 'Узор' },
options: {
pattern: { 'floyd-steinberg': 'Флойд–Стейнберг', bayer: 'Байер 4×4' }
}
},
'show-transparent-png': { 'show-transparent-png': {
title: 'Показать прозрачные области PNG', title: 'Показать прозрачные области PNG',
description: description:
+87 -3
View File
@@ -97,6 +97,11 @@ import {
renderPredicateMask renderPredicateMask
} from './core/masks'; } from './core/masks';
import { renderSpace, SPACES, type SpaceId } from './core/channels'; import { renderSpace, SPACES, type SpaceId } from './core/channels';
import {
ditherImage,
mapToNearest,
quantizeImage
} from './core/quantize';
import { import {
boxTest, boxTest,
circleTest, circleTest,
@@ -2284,9 +2289,88 @@ export const TOOLS: ToolEntry[] = [
{ id: 'color', label: 'Tint color', type: 'color', default: '#ffb060' }, { id: 'color', label: 'Tint color', type: 'color', default: '#ffb060' },
{ id: 'strength', label: 'Strength, %', type: 'slider', min: 0, max: 100, step: 1, default: 30 } { id: 'strength', label: 'Strength, %', type: 'slider', min: 0, max: 100, step: 1, default: 30 }
], ],
run: (img, p) => tint(img, str(p, 'color'), num(p, 'strength')) run: (img, p) => tint(img, str(p, 'color'), num(p, 'strength'))
}, },
{ {
id: 'quantize-png',
title: 'Quantize PNG',
description:
'Reduces the image to k colors via median-cut palette. Transparent pixels are preserved.',
category: 'color',
params: [
{ id: 'colors', label: 'Colors (k)', type: 'slider', min: 2, max: 64, step: 1, default: 16 }
],
run: (img, p) => quantizeImage(img, num(p, 'colors')).image
},
{
id: 'decrease-color-count-png',
title: 'Decrease Color Count PNG',
description:
'Same median-cut engine with fixed power-of-two presets — quick way to drop to 2256 colors.',
category: 'color',
params: [
{
id: 'maxColors',
label: 'Max colors',
type: 'select',
default: '16',
options: [
{ value: '2', label: '2' },
{ value: '4', label: '4' },
{ value: '8', label: '8' },
{ value: '16', label: '16' },
{ value: '32', label: '32' },
{ value: '64', label: '64' },
{ value: '128', label: '128' },
{ value: '256', label: '256' }
]
}
],
run: (img, p) => quantizeImage(img, num(p, 'maxColors')).image
},
{
id: 'custom-palette-png',
title: 'Custom Palette PNG',
description:
'Maps every pixel to the nearest color from your comma-separated hex list.',
category: 'color',
params: [
{
id: 'colors',
label: 'Palette (comma-separated hex)',
type: 'text',
default: '#000000,#ffffff'
}
],
run: (img, p) => mapToNearest(img, parseHexList(str(p, 'colors')))
},
{
id: 'dithering-png',
title: 'Dithering PNG',
description:
'Applies FloydSteinberg error diffusion or ordered Bayer dithering while reducing to k colors.',
category: 'color',
params: [
{ id: 'colors', label: 'Colors (k)', type: 'slider', min: 2, max: 16, step: 1, default: 4 },
{
id: 'pattern',
label: 'Pattern',
type: 'select',
default: 'floyd-steinberg',
options: [
{ value: 'floyd-steinberg', label: 'FloydSteinberg' },
{ value: 'bayer', label: 'Bayer 4×4' }
]
}
],
run: (img, p) =>
ditherImage(
img,
num(p, 'colors'),
str(p, 'pattern') === 'bayer' ? 'bayer' : 'floyd-steinberg'
)
},
{
id: 'svg-to-png', id: 'svg-to-png',
title: 'SVG to PNG', title: 'SVG to PNG',
description: description:
+4
View File
@@ -117,6 +117,10 @@ export const TOOL_ICONS: Record<string, typeof AppWindow> = {
'dark-pixel-mask-png': Moon, 'dark-pixel-mask-png': Moon,
'unique-color-mask-png': Dices, 'unique-color-mask-png': Dices,
'extract-color-from-png': Focus, 'extract-color-from-png': Focus,
'quantize-png': Contrast,
'decrease-color-count-png': Scaling,
'custom-palette-png': Palette,
'dithering-png': Dices,
'feather-edges-png': Droplets, 'feather-edges-png': Droplets,
'clean-edges-png': Scissors, 'clean-edges-png': Scissors,
'pixelate-png': Grid3x3, 'pixelate-png': Grid3x3,