Skip to content

Commit

Permalink
added color quantization/reduction utils
Browse files Browse the repository at this point in the history
  • Loading branch information
jtlapp committed Jan 17, 2018
1 parent 4aa1eef commit 3f499a4
Show file tree
Hide file tree
Showing 33 changed files with 571 additions and 109 deletions.
24 changes: 24 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,31 @@ export namespace GifUtil {
palettes: GifPalette[]
}
function getMaxDimensions(frames: GifFrame[]): { maxWidth: number, maxHeight: number };
function quantizeDekker(imageOrImages: BitmapImage|BitmapImage[], maxColorIndexes: number,
dither?: Dither): this;
function quantizeSorokin(imageOrImages: BitmapImage|BitmapImage[], maxColorIndexes: number,
histogram?: string, dither?: Dither): this;
function quantizeWu(imageOrImages: BitmapImage|BitmapImage[], maxColorIndexes: number,
significantBits?: number, dither?: Dither): this;
function read(source: string|Buffer, decoder?: GifDecoder): Promise<Gif>;
function write(path: string, frames: GifFrame[], spec?: GifSpec, encoder?: GifEncoder):
Promise<Gif>;
}

export type DitherAlgorithm =
'FloydSteinberg' |
'FalseFloydSteinberg' |
'Stucki' |
'Atkinson' |
'Jarvis' |
'Burkes' |
'Sierra' |
'TwoSierra' |
'SierraLite';

export type Dither = {
ditherAlgorithm: DitherAlgorithm,
minimumColorDistanceToDither?: number, // default = 0
serpentine?: boolean, // default = true
calculateErrorLikeGIMP?: boolean // default = false
};
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "A Jimp-compatible library for working with GIFs",
"main": "src/index.js",
"scripts": {
"test": "mocha --timeout=5000 ./test/*.js",
"test": "mocha --timeout=6000 ./test/*.js",
"docs": "jsdoc2md --plugin dmd-clear --template templates/README.hbs src/index.js src/gif.js src/bitmapimage.js src/gifframe.js src/gifutil.js src/gifcodec.js > README.md"
},
"repository": {
Expand All @@ -27,6 +27,7 @@
},
"homepage": "https://github.com/jtlapp/gifwrap#readme",
"dependencies": {
"image-q": "^1.1.1",
"omggif": "^1.0.9"
},
"devDependencies": {
Expand Down
42 changes: 42 additions & 0 deletions src/bitmapimage.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,48 @@ class BitmapImage {
return this;
}

/**
* Get a summary of the colors found within the image. The return value is an object of the following form:
*
* Property | Description
* --- | ---
* colors | An array of all the opaque colors found within the image. Each color is given as an RGB number of the form 0xRRGGBB. The array is sorted by increasing number. Will be an empty array when the image is completely transparent.
* usesTransparency | boolean indicating whether there are any transparent pixels within the image. A pixel is considered transparent if its alpha value is not 0xFF.
* indexCount | The number of color indexes required to represent this palette of colors. It is equal to the number of opaque colors plus one if the image includes transparency.
*
* @return {object} An object representing a color palette as described above.
*/

getPalette() {
// returns with colors sorted low to high
const colorSet = new Set();
const buf = this.bitmap.data;
let i = 0;
let usesTransparency = false;
while (i < buf.length) {
if (buf[i + 3] < 255) {
usesTransparency = true;
}
else {
// can eliminate the bitshift by starting one byte prior
const color = (buf.readUInt32BE(i, true) >> 8) & 0xFFFFFF;
colorSet.add(color);
}
i += 4; // skip alpha
}
const colors = new Array(colorSet.size);
const iter = colorSet.values();
for (i = 0; i < colors.length; ++i) {
colors[i] = iter.next().value;
}
colors.sort((a, b) => (a - b));
let indexCount = colors.length;
if (usesTransparency) {
++indexCount;
}
return { colors, usesTransparency, indexCount };
}

/**
* Gets the RGBA number of the pixel at the given coordinate in the form 0xRRGGBBAA, where AA is the alpha value, with 0xFF being opaque.
*
Expand Down
43 changes: 0 additions & 43 deletions src/gifframe.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,49 +62,6 @@ class GifFrame extends BitmapImage {
this.interlaced = options.interlaced || false;
}
}

/**
* Get a summary of the colors found within the frame. The return value is an object of the following form:
*
* Property | Description
* --- | ---
* colors | An array of all the opaque colors found within the frame. Each color is given as an RGB number of the form 0xRRGGBB. The array is sorted by increasing number. Will be an empty array when the frame is completely transparent.
* usesTransparency | boolean indicating whether there are any transparent pixels within the frame. A pixel is considered transparent if its alpha value is not 0xFF.
* indexCount | The number of color indexes required to represent this palette of colors. It is equal to the number of opaque colors plus one if the frame includes transparency.
*
* @return {object} An object representing a color palette as described above.
*/

getPalette() {
// returns with colors sorted low to high
const colorSet = new Set();
const buf = this.bitmap.data;
let i = 0;
let usesTransparency = false;
while (i < buf.length) {
if (buf[i + 3] < 255) {
usesTransparency = true;
}
else {
// can eliminate the bitshift by starting one byte prior
const color = (buf.readUInt32BE(i, true) >> 8) & 0xFFFFFF;
colorSet.add(color);
}
i += 4; // skip alpha
}
const colors = new Array(colorSet.size);
const iter = colorSet.values();
for (i = 0; i < colors.length; ++i) {
colors[i] = iter.next().value;
}
colors.sort((a, b) => (a - b));
let indexCount = colors.length;
if (usesTransparency) {
++indexCount;
}
return { colors, usesTransparency, indexCount };
}

}

GifFrame.DisposeToAnything = 0;
Expand Down
153 changes: 153 additions & 0 deletions src/gifutil.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
/** @namespace GifUtil */

const fs = require('fs');
const ImageQ = require('image-q');

const { GifFrame } = require('./gifframe');
const { GifError } = require('./gif');
const { GifCodec } = require('./gifcodec');
Expand Down Expand Up @@ -109,6 +111,85 @@ exports.getMaxDimensions = function (frames) {
return { maxWidth, maxHeight };
};

/**
* Quantizes colors so that there are at most a given number of color indexes (including transparency) across all provided images. Uses an algorithm by Anthony Dekker.
*
* The method treats different RGBA combinations as different colors, so if the frame has multiple alpha values or multiple RGB values for an alpha value, the caller may first want to normalize them by converting all transparent pixels to the same RGBA values.
*
* The method may increase the number of colors if there are fewer than the provided maximum.
*
* @param {BitmapImage|BitmapImage[]} imageOrImages Image or array of images (such as GifFrame instances) to be color-quantized. Quantizing across multiple images ensures color consistency from frame to frame.
* @param {number} maxColorIndexes The maximum number of color indexes that will exist in the palette after completing quantization. Defaults to 256.
* @param {object} dither (optional) An object configuring the dithering to apply. The properties are as followings, imported from the [`image-q` package](https://github.com/ibezkrovnyi/image-quantization) without explanation:
* - ditherAlgorithm - One of 'FloydSteinberg' | 'FalseFloydSteinberg' | 'Stucki' | 'Atkinson' 'Jarvis' | 'Burkes' | 'Sierra' | 'TwoSierra' | 'SierraLite'.
* - minimumColorDistanceToDither - (optional) A number defaulting to 0.
* - serpentine - (optional) A boolean defaulting to true.
* - calculateErrorLikeGIMP - (optional) A boolean defaulting to false.
* @see quantizeSorokin
* @see quantizeWu
*/

exports.quantizeDekker = function (imageOrImages, maxColorIndexes, dither) {
maxColorIndexes = maxColorIndexes || 256;
_quantize(imageOrImages, 'NeuQuantFloat', maxColorIndexes, 0, dither);
}

/**
* Quantizes colors so that there are at most a given number of color indexes (including transparency) across all provided images. Uses an algorithm by Leon Sorokin. This quantization method differs from the other two by likely never increasing the number of colors, should there be fewer than the provided maximum.
*
* The method treats different RGBA combinations as different colors, so if the frame has multiple alpha values or multiple RGB values for an alpha value, the caller may first want to normalize them by converting all transparent pixels to the same RGBA values.
*
* @param {BitmapImage|BitmapImage[]} imageOrImages Image or array of images (such as GifFrame instances) to be color-quantized. Quantizing across multiple images ensures color consistency from frame to frame.
* @param {number} maxColorIndexes The maximum number of color indexes that will exist in the palette after completing quantization. Defaults to 256.
* @param {string} histogram (optional) Histogram method: 'top-pop' for global top-population, 'min-pop' for minimum-population threshhold within subregions. Defaults to 'min-pop'.
* @param {object} dither (optional) An object configuring the dithering to apply, as explained for `quantizeDekker()`.
* @see quantizeDekker
* @see quantizeWu
*/

exports.quantizeSorokin = function (imageOrImages, maxColorIndexes, histogram, dither) {
maxColorIndexes = maxColorIndexes || 256;
histogram = histogram || 'min-pop';
let histogramID;
switch (histogram) {
case 'min-pop':
histogramID = 2;
break;

case 'top-pop':
histogramID = 1;
break

default:
throw new Error(`Invalid quantizeSorokin histogram '${histogram}'`);
}
_quantize(imageOrImages, 'RGBQuant', maxColorIndexes, histogramID, dither);
}

/**
* Quantizes colors so that there are at most a given number of color indexes (including transparency) across all provided images. Uses an algorithm by Xiaolin Wu.
*
* The method treats different RGBA combinations as different colors, so if the frame has multiple alpha values or multiple RGB values for an alpha value, the caller may first want to normalize them by converting all transparent pixels to the same RGBA values.
*
* The method may increase the number of colors if there are fewer than the provided maximum.
*
* @param {BitmapImage|BitmapImage[]} imageOrImages Image or array of images (such as GifFrame instances) to be color-quantized. Quantizing across multiple images ensures color consistency from frame to frame.
* @param {number} maxColorIndexes The maximum number of color indexes that will exist in the palette after completing quantization. Defaults to 256.
* @param {number} significantBits (optional) This is the number of significant high bits in each RGB color channel. Takes integer values from 1 through 8. Higher values correspond to higher quality. Defaults to 5.
* @param {object} dither (optional) An object configuring the dithering to apply, as explained for `quantizeDekker()`.
* @see quantizeDekker
* @see quantizeSorokin
*/

exports.quantizeWu = function (imageOrImages, maxColorIndexes, significantBits, dither) {
maxColorIndexes = maxColorIndexes || 256;
significantBits = significantBits || 5;
if (significantBits < 1 || significantBits > 8) {
throw new Error("Invalid quantization quality");
}
_quantize(imageOrImages, 'WuQuant', maxColorIndexes, significantBits, dither);
}

/**
* read() decodes an encoded GIF, whether provided as a filename or as a byte buffer.
*
Expand Down Expand Up @@ -163,6 +244,78 @@ exports.write = function (path, frames, spec, encoder) {
});
};

function _quantize(imageOrImages, method, maxColorIndexes, modifier, dither) {
const images = Array.isArray(imageOrImages) ? imageOrImages : [imageOrImages];
const ditherAlgs = [
'FloydSteinberg',
'FalseFloydSteinberg',
'Stucki',
'Atkinson',
'Jarvis',
'Burkes',
'Sierra',
'TwoSierra',
'SierraLite'
];

if (dither) {
if (ditherAlgs.indexOf(dither.ditherAlgorithm) < 0) {
throw new Error(`Invalid ditherAlgorithm '${dither.ditherAlgorithm}'`);
}
if (dither.serpentine === undefined) {
dither.serpentine = true;
}
if (dither.minimumColorDistanceToDither === undefined) {
dither.minimumColorDistanceToDither = 0;
}
if (dither.calculateErrorLikeGIMP === undefined) {
dither.calculateErrorLikeGIMP = false;
}
}

const distCalculator = new ImageQ.distance.Euclidean();
const quantizer = new ImageQ.palette[method](distCalculator, maxColorIndexes, modifier);
let imageMaker;
if (dither) {
imageMaker = new ImageQ.image.ErrorDiffusionArray(
distCalculator,
ImageQ.image.ErrorDiffusionArrayKernel[dither.ditherAlgorithm],
dither.serpentine,
dither.minimumColorDistanceToDither,
dither.calculateErrorLikeGIMP
);
}
else {
imageMaker = new ImageQ.image.NearestColor(distCalculator);
}

const inputContainers = [];
images.forEach(image => {

const imageBuf = image.bitmap.data;
const inputBuf = new ArrayBuffer(imageBuf.length);
const inputArray = new Uint32Array(inputBuf);
for (let bi = 0, ai = 0; bi < imageBuf.length; bi += 4, ++ai) {
inputArray[ai] = imageBuf.readUInt32LE(bi, true);
}
const inputContainer = ImageQ.utils.PointContainer.fromUint32Array(
inputArray, image.bitmap.width, image.bitmap.height);
quantizer.sample(inputContainer);
inputContainers.push(inputContainer);
});

const limitedPalette = quantizer.quantize();

for (let i = 0; i < images.length; ++i) {
const imageBuf = images[i].bitmap.data;
const outputContainer = imageMaker.quantize(inputContainers[i], limitedPalette);
const outputArray = outputContainer.toUint32Array();
for (let bi = 0, ai = 0; bi < imageBuf.length; bi += 4, ++ai) {
imageBuf.writeUInt32LE(outputArray[ai], bi);
}
}
}

function _readBinary(path) {
// TBD: add support for URLs
return new Promise((resolve, reject) => {
Expand Down
Binary file added test/fixtures/hairstreak.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/penguins.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/quantized/hairstreak256_Dekker.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/quantized/hairstreak256_Wu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/quantized/hairstreak32_Dekker.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/quantized/hairstreak32_Sorokin.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/quantized/hairstreak32_Wu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/quantized/rosewithtrans256_Wu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/quantized/rosewithtrans32_Wu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/quantized/sculptmap256_Dekker.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/quantized/sculptmap256_Sorokin.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/quantized/sculptmap256_Wu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/quantized/sculptmap32_Dekker.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/quantized/sculptmap32_Sorokin.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/quantized/sculptmap32_Wu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/rosewithtrans.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/sculptmap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 3f499a4

Please sign in to comment.