From 501d2dda4e186b2f7ece3f1be41b552393ca18a3 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Wed, 26 Aug 2026 17:31:24 +0500 Subject: [PATCH] feat: add quantize tools --- docs/plan-gap-waves.md | 2 +- docs/tools-map.md | 9 +- web/src/lib/core/quantize.test.ts | 82 ++++++++++++ web/src/lib/core/quantize.ts | 210 ++++++++++++++++++++++++++++++ web/src/lib/i18n/ru.ts | 29 +++++ web/src/lib/registry.ts | 90 ++++++++++++- web/src/lib/tools/tool-icons.ts | 4 + 7 files changed, 421 insertions(+), 5 deletions(-) create mode 100644 web/src/lib/core/quantize.test.ts create mode 100644 web/src/lib/core/quantize.ts diff --git a/docs/plan-gap-waves.md b/docs/plan-gap-waves.md index e3b45d7..1c25a49 100644 --- a/docs/plan-gap-waves.md +++ b/docs/plan-gap-waves.md @@ -49,7 +49,7 @@ text-to-png (domText без входной картинки), emoji-to-png, plac color-spectrum, colorful-random (seed), draw-grid. multi-color-gradient — если успеем новый тип параметра «список цветов», иначе перенос. -### W8. Цветовые MEDIUM — 4 инструмента, L +### W8. Цветовые MEDIUM — ВЫПОЛНЕНА (4 инструмента; median-cut + Floyd–Steinberg/Bayer, custom-palette через text-параметр) Ядро: квантование (median-cut или k-means). Состав: quantize (k), decrease-color-count (=quantize с пресетами), custom-palette (маппинг на список цветов — нужен тип параметра «список»), dithering (Floyd–Steinberg/Bayer поверх квантования). diff --git a/docs/tools-map.md b/docs/tools-map.md index 05fe740..b4830d3 100644 --- a/docs/tools-map.md +++ b/docs/tools-map.md @@ -64,6 +64,13 @@ - gamma-png — value - 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 (Floyd–Steinberg / Bayer 4×4) + ### Разложение каналов - 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 (итеративный поиск) - optimize-png — пресеты - change-quality — честная семантика для lossless (см. план-гапы §риск) -- low-quality-png — частично покрыт jpeg-artifacts; остаток = сильный quantize +- low-quality-png — частично покрыт jpeg-artifacts + quantize ### Генераторы — остаток diff --git a/web/src/lib/core/quantize.test.ts b/web/src/lib/core/quantize.test.ts new file mode 100644 index 0000000..ea5b351 --- /dev/null +++ b/web/src/lib/core/quantize.test.ts @@ -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); + }); +}); diff --git a/web/src/lib/core/quantize.ts b/web/src/lib/core/quantize.ts new file mode 100644 index 0000000..1b10df5 --- /dev/null +++ b/web/src/lib/core/quantize.ts @@ -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; +} diff --git a/web/src/lib/i18n/ru.ts b/web/src/lib/i18n/ru.ts index dac8a92..91a8a0b 100644 --- a/web/src/lib/i18n/ru.ts +++ b/web/src/lib/i18n/ru.ts @@ -853,6 +853,35 @@ export const ru: Dict = { 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': { title: 'Показать прозрачные области PNG', description: diff --git a/web/src/lib/registry.ts b/web/src/lib/registry.ts index 605beaf..6aadf9e 100644 --- a/web/src/lib/registry.ts +++ b/web/src/lib/registry.ts @@ -97,6 +97,11 @@ import { renderPredicateMask } from './core/masks'; import { renderSpace, SPACES, type SpaceId } from './core/channels'; +import { + ditherImage, + mapToNearest, + quantizeImage +} from './core/quantize'; import { boxTest, circleTest, @@ -2284,9 +2289,88 @@ export const TOOLS: ToolEntry[] = [ { id: 'color', label: 'Tint color', type: 'color', default: '#ffb060' }, { 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 2–256 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 Floyd–Steinberg 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: 'Floyd–Steinberg' }, + { 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', title: 'SVG to PNG', description: diff --git a/web/src/lib/tools/tool-icons.ts b/web/src/lib/tools/tool-icons.ts index a32d982..937121b 100644 --- a/web/src/lib/tools/tool-icons.ts +++ b/web/src/lib/tools/tool-icons.ts @@ -117,6 +117,10 @@ export const TOOL_ICONS: Record = { 'dark-pixel-mask-png': Moon, 'unique-color-mask-png': Dices, 'extract-color-from-png': Focus, + 'quantize-png': Contrast, + 'decrease-color-count-png': Scaling, + 'custom-palette-png': Palette, + 'dithering-png': Dices, 'feather-edges-png': Droplets, 'clean-edges-png': Scissors, 'pixelate-png': Grid3x3,