UNPKG

@poupe/material-color-utilities

Version:

Algorithms and utilities that power the Material Design 3 (M3) color system, including choosing theme colors from images and creating tones of colors; all in a new color space.

374 lines 15.9 kB
/** * @license * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import * as utils from '../utils/color_utils.js'; import { QuantizerMap } from './quantizer_map.js'; const INDEX_BITS = 5; const SIDE_LENGTH = 33; // ((1 << INDEX_INDEX_BITS) + 1) const TOTAL_SIZE = 35937; // SIDE_LENGTH * SIDE_LENGTH * SIDE_LENGTH const directions = { RED: 'red', GREEN: 'green', BLUE: 'blue', }; /** * An image quantizer that divides the image's pixels into clusters by * recursively cutting an RGB cube, based on the weight of pixels in each area * of the cube. * * The algorithm was described by Xiaolin Wu in Graphic Gems II, published in * 1991. */ export class QuantizerWu { constructor(weights = [], momentsR = [], momentsG = [], momentsB = [], moments = [], cubes = []) { this.weights = weights; this.momentsR = momentsR; this.momentsG = momentsG; this.momentsB = momentsB; this.moments = moments; this.cubes = cubes; } /** * @param pixels Colors in ARGB format. * @param maxColors The number of colors to divide the image into. A lower * number of colors may be returned. * @return Colors in ARGB format. */ quantize(pixels, maxColors) { this.constructHistogram(pixels); this.computeMoments(); const createBoxesResult = this.createBoxes(maxColors); const results = this.createResult(createBoxesResult.resultCount); return results; } constructHistogram(pixels) { this.weights = Array.from({ length: TOTAL_SIZE }).fill(0); this.momentsR = Array.from({ length: TOTAL_SIZE }).fill(0); this.momentsG = Array.from({ length: TOTAL_SIZE }).fill(0); this.momentsB = Array.from({ length: TOTAL_SIZE }).fill(0); this.moments = Array.from({ length: TOTAL_SIZE }).fill(0); const countByColor = QuantizerMap.quantize(pixels); for (const [pixel, count] of countByColor.entries()) { const red = utils.redFromArgb(pixel); const green = utils.greenFromArgb(pixel); const blue = utils.blueFromArgb(pixel); const bitsToRemove = 8 - INDEX_BITS; const iR = (red >> bitsToRemove) + 1; const iG = (green >> bitsToRemove) + 1; const iB = (blue >> bitsToRemove) + 1; const index = this.getIndex(iR, iG, iB); this.weights[index] = (this.weights[index] ?? 0) + count; this.momentsR[index] += count * red; this.momentsG[index] += count * green; this.momentsB[index] += count * blue; this.moments[index] += count * (red * red + green * green + blue * blue); } } computeMoments() { for (let r = 1; r < SIDE_LENGTH; r++) { const area = Array.from({ length: SIDE_LENGTH }).fill(0); const areaR = Array.from({ length: SIDE_LENGTH }).fill(0); const areaG = Array.from({ length: SIDE_LENGTH }).fill(0); const areaB = Array.from({ length: SIDE_LENGTH }).fill(0); const area2 = Array.from({ length: SIDE_LENGTH }).fill(0.0); for (let g = 1; g < SIDE_LENGTH; g++) { let line = 0; let lineR = 0; let lineG = 0; let lineB = 0; let line2 = 0.0; for (let b = 1; b < SIDE_LENGTH; b++) { const index = this.getIndex(r, g, b); line += this.weights[index]; lineR += this.momentsR[index]; lineG += this.momentsG[index]; lineB += this.momentsB[index]; line2 += this.moments[index]; area[b] += line; areaR[b] += lineR; areaG[b] += lineG; areaB[b] += lineB; area2[b] += line2; const previousIndex = this.getIndex(r - 1, g, b); this.weights[index] = this.weights[previousIndex] + area[b]; this.momentsR[index] = this.momentsR[previousIndex] + areaR[b]; this.momentsG[index] = this.momentsG[previousIndex] + areaG[b]; this.momentsB[index] = this.momentsB[previousIndex] + areaB[b]; this.moments[index] = this.moments[previousIndex] + area2[b]; } } } } createBoxes(maxColors) { this.cubes = Array.from({ length: maxColors }).fill(0).map(() => new Box()); const volumeVariance = Array.from({ length: maxColors }).fill(0.0); this.cubes[0].r0 = 0; this.cubes[0].g0 = 0; this.cubes[0].b0 = 0; this.cubes[0].r1 = SIDE_LENGTH - 1; this.cubes[0].g1 = SIDE_LENGTH - 1; this.cubes[0].b1 = SIDE_LENGTH - 1; let generatedColorCount = maxColors; let next = 0; for (let i = 1; i < maxColors; i++) { if (this.cut(this.cubes[next], this.cubes[i])) { volumeVariance[next] = this.cubes[next].vol > 1 ? this.variance(this.cubes[next]) : 0.0; volumeVariance[i] = this.cubes[i].vol > 1 ? this.variance(this.cubes[i]) : 0.0; } else { volumeVariance[next] = 0.0; i--; } next = 0; let temp = volumeVariance[0]; for (let j = 1; j <= i; j++) { if (volumeVariance[j] > temp) { temp = volumeVariance[j]; next = j; } } if (temp <= 0.0) { generatedColorCount = i + 1; break; } } return new CreateBoxesResult(maxColors, generatedColorCount); } createResult(colorCount) { const colors = []; for (let i = 0; i < colorCount; ++i) { const cube = this.cubes[i]; const weight = this.volume(cube, this.weights); if (weight > 0) { const r = Math.round(this.volume(cube, this.momentsR) / weight); const g = Math.round(this.volume(cube, this.momentsG) / weight); const b = Math.round(this.volume(cube, this.momentsB) / weight); const color = (255 << 24) | ((r & 0x0ff) << 16) | ((g & 0x0ff) << 8) | (b & 0x0ff); colors.push(color); } } return colors; } variance(cube) { const dr = this.volume(cube, this.momentsR); const dg = this.volume(cube, this.momentsG); const db = this.volume(cube, this.momentsB); const xx = this.moments[this.getIndex(cube.r1, cube.g1, cube.b1)] - this.moments[this.getIndex(cube.r1, cube.g1, cube.b0)] - this.moments[this.getIndex(cube.r1, cube.g0, cube.b1)] + this.moments[this.getIndex(cube.r1, cube.g0, cube.b0)] - this.moments[this.getIndex(cube.r0, cube.g1, cube.b1)] + this.moments[this.getIndex(cube.r0, cube.g1, cube.b0)] + this.moments[this.getIndex(cube.r0, cube.g0, cube.b1)] - this.moments[this.getIndex(cube.r0, cube.g0, cube.b0)]; const hypotenuse = dr * dr + dg * dg + db * db; const volume = this.volume(cube, this.weights); return xx - hypotenuse / volume; } cut(one, two) { const wholeR = this.volume(one, this.momentsR); const wholeG = this.volume(one, this.momentsG); const wholeB = this.volume(one, this.momentsB); const wholeW = this.volume(one, this.weights); const maxRResult = this.maximize(one, directions.RED, one.r0 + 1, one.r1, wholeR, wholeG, wholeB, wholeW); const maxGResult = this.maximize(one, directions.GREEN, one.g0 + 1, one.g1, wholeR, wholeG, wholeB, wholeW); const maxBResult = this.maximize(one, directions.BLUE, one.b0 + 1, one.b1, wholeR, wholeG, wholeB, wholeW); let direction; const maxR = maxRResult.maximum; const maxG = maxGResult.maximum; const maxB = maxBResult.maximum; if (maxR >= maxG && maxR >= maxB) { if (maxRResult.cutLocation < 0) { return false; } direction = directions.RED; } else if (maxG >= maxR && maxG >= maxB) { direction = directions.GREEN; } else { direction = directions.BLUE; } two.r1 = one.r1; two.g1 = one.g1; two.b1 = one.b1; switch (direction) { case directions.RED: one.r1 = maxRResult.cutLocation; two.r0 = one.r1; two.g0 = one.g0; two.b0 = one.b0; break; case directions.GREEN: one.g1 = maxGResult.cutLocation; two.r0 = one.r0; two.g0 = one.g1; two.b0 = one.b0; break; case directions.BLUE: one.b1 = maxBResult.cutLocation; two.r0 = one.r0; two.g0 = one.g0; two.b0 = one.b1; break; default: throw new Error('unexpected direction ' + direction); } one.vol = (one.r1 - one.r0) * (one.g1 - one.g0) * (one.b1 - one.b0); two.vol = (two.r1 - two.r0) * (two.g1 - two.g0) * (two.b1 - two.b0); return true; } maximize(cube, direction, first, last, wholeR, wholeG, wholeB, wholeW) { const bottomR = this.bottom(cube, direction, this.momentsR); const bottomG = this.bottom(cube, direction, this.momentsG); const bottomB = this.bottom(cube, direction, this.momentsB); const bottomW = this.bottom(cube, direction, this.weights); let max = 0.0; let cut = -1; let halfR = 0; let halfG = 0; let halfB = 0; let halfW = 0; for (let i = first; i < last; i++) { halfR = bottomR + this.top(cube, direction, i, this.momentsR); halfG = bottomG + this.top(cube, direction, i, this.momentsG); halfB = bottomB + this.top(cube, direction, i, this.momentsB); halfW = bottomW + this.top(cube, direction, i, this.weights); if (halfW === 0) { continue; } let tempNumerator = (halfR * halfR + halfG * halfG + halfB * halfB) * 1.0; let tempDenominator = halfW * 1.0; let temp = tempNumerator / tempDenominator; halfR = wholeR - halfR; halfG = wholeG - halfG; halfB = wholeB - halfB; halfW = wholeW - halfW; if (halfW === 0) { continue; } tempNumerator = (halfR * halfR + halfG * halfG + halfB * halfB) * 1.0; tempDenominator = halfW * 1.0; temp += tempNumerator / tempDenominator; if (temp > max) { max = temp; cut = i; } } return new MaximizeResult(cut, max); } volume(cube, moment) { return (moment[this.getIndex(cube.r1, cube.g1, cube.b1)] - moment[this.getIndex(cube.r1, cube.g1, cube.b0)] - moment[this.getIndex(cube.r1, cube.g0, cube.b1)] + moment[this.getIndex(cube.r1, cube.g0, cube.b0)] - moment[this.getIndex(cube.r0, cube.g1, cube.b1)] + moment[this.getIndex(cube.r0, cube.g1, cube.b0)] + moment[this.getIndex(cube.r0, cube.g0, cube.b1)] - moment[this.getIndex(cube.r0, cube.g0, cube.b0)]); } bottom(cube, direction, moment) { switch (direction) { case directions.RED: return (-moment[this.getIndex(cube.r0, cube.g1, cube.b1)] + moment[this.getIndex(cube.r0, cube.g1, cube.b0)] + moment[this.getIndex(cube.r0, cube.g0, cube.b1)] - moment[this.getIndex(cube.r0, cube.g0, cube.b0)]); case directions.GREEN: return (-moment[this.getIndex(cube.r1, cube.g0, cube.b1)] + moment[this.getIndex(cube.r1, cube.g0, cube.b0)] + moment[this.getIndex(cube.r0, cube.g0, cube.b1)] - moment[this.getIndex(cube.r0, cube.g0, cube.b0)]); case directions.BLUE: return (-moment[this.getIndex(cube.r1, cube.g1, cube.b0)] + moment[this.getIndex(cube.r1, cube.g0, cube.b0)] + moment[this.getIndex(cube.r0, cube.g1, cube.b0)] - moment[this.getIndex(cube.r0, cube.g0, cube.b0)]); default: throw new Error('unexpected direction $direction'); } } top(cube, direction, position, moment) { switch (direction) { case directions.RED: return (moment[this.getIndex(position, cube.g1, cube.b1)] - moment[this.getIndex(position, cube.g1, cube.b0)] - moment[this.getIndex(position, cube.g0, cube.b1)] + moment[this.getIndex(position, cube.g0, cube.b0)]); case directions.GREEN: return (moment[this.getIndex(cube.r1, position, cube.b1)] - moment[this.getIndex(cube.r1, position, cube.b0)] - moment[this.getIndex(cube.r0, position, cube.b1)] + moment[this.getIndex(cube.r0, position, cube.b0)]); case directions.BLUE: return (moment[this.getIndex(cube.r1, cube.g1, position)] - moment[this.getIndex(cube.r1, cube.g0, position)] - moment[this.getIndex(cube.r0, cube.g1, position)] + moment[this.getIndex(cube.r0, cube.g0, position)]); default: throw new Error('unexpected direction $direction'); } } getIndex(r, g, b) { return (r << (INDEX_BITS * 2)) + (r << (INDEX_BITS + 1)) + r + (g << INDEX_BITS) + g + b; } } /** * Keeps track of the state of each box created as the Wu quantization * algorithm progresses through dividing the image's pixels as plotted in RGB. */ class Box { constructor(r0 = 0, r1 = 0, g0 = 0, g1 = 0, b0 = 0, b1 = 0, vol = 0) { this.r0 = r0; this.r1 = r1; this.g0 = g0; this.g1 = g1; this.b0 = b0; this.b1 = b1; this.vol = vol; } } /** * Represents final result of Wu algorithm. */ class CreateBoxesResult { /** * @param requestedCount how many colors the caller asked to be returned from * quantization. * @param resultCount the actual number of colors achieved from quantization. * May be lower than the requested count. */ constructor(requestedCount, resultCount) { this.requestedCount = requestedCount; this.resultCount = resultCount; } } /** * Represents the result of calculating where to cut an existing box in such * a way to maximize variance between the two new boxes created by a cut. */ class MaximizeResult { constructor(cutLocation, maximum) { this.cutLocation = cutLocation; this.maximum = maximum; } } //# sourceMappingURL=quantizer_wu.js.map