diff --git a/demo/css/demo.css b/demo/css/demo.css index 46fc18a..0ff9fbd 100644 --- a/demo/css/demo.css +++ b/demo/css/demo.css @@ -91,4 +91,23 @@ input[type="text"] { pre { margin: 10px 0 0 0; -} \ No newline at end of file +} + +#orig, #redu { + background-image: + -moz-linear-gradient(45deg, #000 25%, transparent 25%), + -moz-linear-gradient(-45deg, #000 25%, transparent 25%), + -moz-linear-gradient(45deg, transparent 75%, #000 75%), + -moz-linear-gradient(-45deg, transparent 75%, #000 75%); + background-image: + -webkit-gradient(linear, 0 100%, 100% 0, color-stop(.25, #000), color-stop(.25, transparent)), + -webkit-gradient(linear, 0 0, 100% 100%, color-stop(.25, #000), color-stop(.25, transparent)), + -webkit-gradient(linear, 0 100%, 100% 0, color-stop(.75, transparent), color-stop(.75, #000)), + -webkit-gradient(linear, 0 0, 100% 100%, color-stop(.75, transparent), color-stop(.75, #000)); + + -moz-background-size:20px 20px; + background-size:20px 20px; + -webkit-background-size:20px 21px; /* override value for shitty webkit */ + + background-position:0 0, 10px 0, 10px -10px, 0px 10px; +} diff --git a/demo/index.html b/demo/index.html index b3acd9a..b41649d 100644 --- a/demo/index.html +++ b/demo/index.html @@ -7,7 +7,7 @@ - + @@ -94,6 +94,43 @@

RgbQuant.js

A single, optimal palette is progressivley generated for a set of images. Each is then reduced using it.
+ + Alpha + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + dithKern: + }; @@ -130,4 +167,4 @@

Reduced

- \ No newline at end of file + diff --git a/demo/js/demo.js b/demo/js/demo.js index 9ea45bd..6c73202 100644 --- a/demo/js/demo.js +++ b/demo/js/demo.js @@ -1,12 +1,9 @@ var cfg_edited = false; var dflt_opts = { - colors: 256, + colors: 1024, method: 2, - initColors: 4096, - minHueCols: 0, - dithKern: null, - dithSerp: false, + dithKern: "SierraLite" }; var cfgs = { @@ -80,7 +77,8 @@ function process(srcs) { ti.start(); $.getImgs(srcs, function() { - var imgs = arguments; + var imgs = []; + for(var i = 0, l = arguments.length; i < l; i++) imgs[i] = arguments[i]; ti.mark("image(s) loaded"); @@ -93,32 +91,65 @@ function process(srcs) { }); var opts = (srcs.length == 1) ? getOpts(baseName(srcs[0])[0]) : dflt_opts, - quant = new RgbQuant(opts); + quant = new ColorQuantization.RgbQuant(opts), + pointBuffers = []; - $.each(imgs, function() { - var img = this, id = baseName(img.src)[0]; + + ti.mark("create pointBuffers", function() { + imgs.forEach(function (img, index) { + pointBuffers[index] = new ColorQuantization.PointBuffer(); + pointBuffers[index].importHTMLImageElement(img); + }); + }); + + imgs.forEach(function (img, index) { + var id = baseName(img.src)[0]; ti.mark("sample '" + id + "'", function(){ - quant.sample(img); + quant.sample(pointBuffers[index]); }); }); var pal8; ti.mark("build palette", function() { pal8 = quant.palette(); + //pal8 = quant.paletteMedianCut(); }); - var pcan = drawPixels(pal8, 16, 128); + // TODO: temporary solution. see Palette class todo + var uint32Array = pal8._paletteArray.map(function(point) { return point.uint32 }); + var uint8array = new Uint8Array((new Uint32Array(uint32Array)).buffer); + + var pcan = drawPixels(uint8array, 16, 128); $palt.empty().append(pcan); $redu.empty(); - $(imgs).each(function() { - var img = this, id = baseName(img.src)[0]; + imgs.forEach(function (img, index) { + var id = baseName(img.src)[0]; var img8; ti.mark("reduce '" + id + "'", function() { - img8 = quant.reduce(img); +/* + pal8 = new ColorQuantization.Palette(); + pal8._paletteArray.push(ColorQuantization.Point.createByRGBA(10,49,4,255)); + pal8._paletteArray.push(ColorQuantization.Point.createByRGBA(80,148,15,255)); + pal8._paletteArray.push(ColorQuantization.Point.createByRGBA(149,172,45,255)); + pal8._paletteArray.push(ColorQuantization.Point.createByRGBA(173,209,79,255)); + pal8._paletteArray.push(ColorQuantization.Point.createByRGBA(181,215,166,255)); + pal8._paletteArray.push(ColorQuantization.Point.createByRGBA(161,176,175,255)); + pal8._paletteArray.push(ColorQuantization.Point.createByRGBA(219,231,196,255)); + pal8._paletteArray.push(ColorQuantization.Point.createByRGBA(56,236,56,255)); + pal8._paletteArray.push(ColorQuantization.Point.createByRGBA(116,167,148,255)); + pal8._paletteArray.push(ColorQuantization.Point.createByRGBA(200,20,128,255)); + pal8._paletteArray.push(ColorQuantization.Point.createByRGBA(54,101,7,255)); + pal8._paletteArray.push(ColorQuantization.Point.createByRGBA(196,94,54,255)); + pal8._paletteArray.push(ColorQuantization.Point.createByRGBA(56,92,200,255)); + pal8._paletteArray.push(ColorQuantization.Point.createByRGBA(58,235,200,255)); + pal8._paletteArray.push(ColorQuantization.Point.createByRGBA(200,92,200,255)); + pal8._paletteArray.push(ColorQuantization.Point.createByRGBA(56,20,200,255)); +*/ + img8 = quant.reduce(pointBuffers[index], pal8).exportUint8Array(); }); ti.mark("reduced -> DOM", function() { @@ -157,4 +188,4 @@ $(document).on("click", "img.th", function() { // process(["img/grad_default.png"]); }).on("change", "input, textarea, select", function() { cfg_edited = true; -}); \ No newline at end of file +}); diff --git a/demo/png/Angry-Minion-icon.png b/demo/png/Angry-Minion-icon.png new file mode 100644 index 0000000..fe0eede Binary files /dev/null and b/demo/png/Angry-Minion-icon.png differ diff --git a/demo/png/Curious-Minion-Icon-2.png b/demo/png/Curious-Minion-Icon-2.png new file mode 100644 index 0000000..5a0d700 Binary files /dev/null and b/demo/png/Curious-Minion-Icon-2.png differ diff --git a/demo/png/Curious-Minion-Icon.png b/demo/png/Curious-Minion-Icon.png new file mode 100644 index 0000000..95c9ae7 Binary files /dev/null and b/demo/png/Curious-Minion-Icon.png differ diff --git a/demo/png/Dancing-minion-icon.png b/demo/png/Dancing-minion-icon.png new file mode 100644 index 0000000..0998762 Binary files /dev/null and b/demo/png/Dancing-minion-icon.png differ diff --git a/demo/png/Edith-despicable-me-2-icon.png b/demo/png/Edith-despicable-me-2-icon.png new file mode 100644 index 0000000..2593d98 Binary files /dev/null and b/demo/png/Edith-despicable-me-2-icon.png differ diff --git a/demo/png/Evil-Minion-Icon-3.png b/demo/png/Evil-Minion-Icon-3.png new file mode 100644 index 0000000..cdf8e97 Binary files /dev/null and b/demo/png/Evil-Minion-Icon-3.png differ diff --git a/demo/png/Evil-Minion-Icon-4.png b/demo/png/Evil-Minion-Icon-4.png new file mode 100644 index 0000000..a8d4722 Binary files /dev/null and b/demo/png/Evil-Minion-Icon-4.png differ diff --git a/demo/png/Happy-Minion-Icon.png b/demo/png/Happy-Minion-Icon.png new file mode 100644 index 0000000..4bf0e90 Binary files /dev/null and b/demo/png/Happy-Minion-Icon.png differ diff --git a/demo/png/Margo-dispicable-me-2-icon.png b/demo/png/Margo-dispicable-me-2-icon.png new file mode 100644 index 0000000..6ec439a Binary files /dev/null and b/demo/png/Margo-dispicable-me-2-icon.png differ diff --git a/demo/png/Minion icon.png b/demo/png/Minion icon.png new file mode 100644 index 0000000..9b5f8fa Binary files /dev/null and b/demo/png/Minion icon.png differ diff --git a/demo/png/Minion-playing-golf-icon.png b/demo/png/Minion-playing-golf-icon.png new file mode 100644 index 0000000..359c27c Binary files /dev/null and b/demo/png/Minion-playing-golf-icon.png differ diff --git a/demo/png/Minion-reading-icon.png b/demo/png/Minion-reading-icon.png new file mode 100644 index 0000000..9ca96f2 Binary files /dev/null and b/demo/png/Minion-reading-icon.png differ diff --git a/demo/png/Sad-Agnes-Icon.png b/demo/png/Sad-Agnes-Icon.png new file mode 100644 index 0000000..bea605b Binary files /dev/null and b/demo/png/Sad-Agnes-Icon.png differ diff --git a/demo/png/Shy-Minion-icon.png b/demo/png/Shy-Minion-icon.png new file mode 100644 index 0000000..384ac31 Binary files /dev/null and b/demo/png/Shy-Minion-icon.png differ diff --git a/demo/png/agnes-overjoyed-icon.png b/demo/png/agnes-overjoyed-icon.png new file mode 100644 index 0000000..bf27104 Binary files /dev/null and b/demo/png/agnes-overjoyed-icon.png differ diff --git a/demo/png/agnes-sleeping-icon.png b/demo/png/agnes-sleeping-icon.png new file mode 100644 index 0000000..1611cb2 Binary files /dev/null and b/demo/png/agnes-sleeping-icon.png differ diff --git a/demo/png/despicable-me-2-Minion-icon-1.png b/demo/png/despicable-me-2-Minion-icon-1.png new file mode 100644 index 0000000..87ed0ac Binary files /dev/null and b/demo/png/despicable-me-2-Minion-icon-1.png differ diff --git a/demo/png/despicable-me-2-Minion-icon-2.png b/demo/png/despicable-me-2-Minion-icon-2.png new file mode 100644 index 0000000..1cdd51c Binary files /dev/null and b/demo/png/despicable-me-2-Minion-icon-2.png differ diff --git a/demo/png/despicable-me-2-Minion-icon-3.png b/demo/png/despicable-me-2-Minion-icon-3.png new file mode 100644 index 0000000..f000ea0 Binary files /dev/null and b/demo/png/despicable-me-2-Minion-icon-3.png differ diff --git a/demo/png/despicable-me-2-Minion-icon-4.png b/demo/png/despicable-me-2-Minion-icon-4.png new file mode 100644 index 0000000..dc7a3a9 Binary files /dev/null and b/demo/png/despicable-me-2-Minion-icon-4.png differ diff --git a/demo/png/despicable-me-2-Minion-icon-5.png b/demo/png/despicable-me-2-Minion-icon-5.png new file mode 100644 index 0000000..e8fa944 Binary files /dev/null and b/demo/png/despicable-me-2-Minion-icon-5.png differ diff --git a/demo/png/despicable-me-2-Minion-icon-6.png b/demo/png/despicable-me-2-Minion-icon-6.png new file mode 100644 index 0000000..b36ec39 Binary files /dev/null and b/demo/png/despicable-me-2-Minion-icon-6.png differ diff --git a/demo/png/despicable-me-2-Minion-icon-7.png b/demo/png/despicable-me-2-Minion-icon-7.png new file mode 100644 index 0000000..85a3345 Binary files /dev/null and b/demo/png/despicable-me-2-Minion-icon-7.png differ diff --git a/demo/png/despicable-me-2-Minion-icon-8.png b/demo/png/despicable-me-2-Minion-icon-8.png new file mode 100644 index 0000000..c3ad145 Binary files /dev/null and b/demo/png/despicable-me-2-Minion-icon-8.png differ diff --git a/demo/png/evil-minion-icon-2.png b/demo/png/evil-minion-icon-2.png new file mode 100644 index 0000000..5d8c20d Binary files /dev/null and b/demo/png/evil-minion-icon-2.png differ diff --git a/demo/png/evil-minion-icon.png b/demo/png/evil-minion-icon.png new file mode 100644 index 0000000..41652bc Binary files /dev/null and b/demo/png/evil-minion-icon.png differ diff --git a/demo/png/girl-minion-icon.png b/demo/png/girl-minion-icon.png new file mode 100644 index 0000000..7e90c30 Binary files /dev/null and b/demo/png/girl-minion-icon.png differ diff --git a/demo/png/gru-icon-2.png b/demo/png/gru-icon-2.png new file mode 100644 index 0000000..25b5c08 Binary files /dev/null and b/demo/png/gru-icon-2.png differ diff --git a/demo/png/gru-icon.png b/demo/png/gru-icon.png new file mode 100644 index 0000000..c1771d2 Binary files /dev/null and b/demo/png/gru-icon.png differ diff --git a/demo/png/happy-agnes-icon.png b/demo/png/happy-agnes-icon.png new file mode 100644 index 0000000..50fe539 Binary files /dev/null and b/demo/png/happy-agnes-icon.png differ diff --git a/demo/png/kungfu-Minion.png b/demo/png/kungfu-Minion.png new file mode 100644 index 0000000..6500ae1 Binary files /dev/null and b/demo/png/kungfu-Minion.png differ diff --git a/demo/png/superman-minion-icon.png b/demo/png/superman-minion-icon.png new file mode 100644 index 0000000..1cf6a80 Binary files /dev/null and b/demo/png/superman-minion-icon.png differ diff --git a/src/rgbquant.js b/src/rgbquant.js index bc0bd1b..cff0e8d 100644 --- a/src/rgbquant.js +++ b/src/rgbquant.js @@ -14,7 +14,7 @@ // desired final palette size this.colors = opts.colors || 256; // # of highest-frequency colors to start with for palette reduction - this.initColors = opts.initColors || 4096; + this.initColors = this.colors << 2;//opts.initColors || 65536; //4096; // color-distance threshold for initial reduction pass this.initDist = opts.initDist || 0.01; // subsequent passes threshold @@ -24,7 +24,7 @@ this.satGroups = opts.satGroups || 10; this.lumGroups = opts.lumGroups || 10; // if > 0, enables hues stats and min-color retention per group - this.minHueCols = opts.minHueCols || 0; + this.minHueCols = this.colors << 2;//opts.minHueCols || 0; // HueStats instance this.hueStats = this.minHueCols ? new HueStats(this.hueGroups, this.minHueCols) : null; @@ -40,7 +40,7 @@ // dithering/error diffusion kernel name this.dithKern = opts.dithKern || null; // dither serpentine pattern - this.dithSerp = opts.dithSerp || false; + this.dithSerp = true; //opts.dithSerp || false; // minimum color difference (0-1) needed to dither this.dithDelta = opts.dithDelta || 0; @@ -65,19 +65,19 @@ // if pre-defined palette, build lookups if (this.idxrgb.length > 0) { - var self = this; this.idxrgb.forEach(function(rgb, i) { - var i32 = ( - (255 << 24) | // alpha - (rgb[2] << 16) | // blue - (rgb[1] << 8) | // green - rgb[0] // red - ) >>> 0; - - self.idxi32[i] = i32; - self.i32idx[i32] = i; - self.i32rgb[i32] = rgb; - }); + var alpha = rgb.length >= 4 ? rgb[3] : 255, + i32 = ( + (alpha << 24) | // alpha + (rgb[2] << 16) | // blue + (rgb[1] << 8) | // green + rgb[0] // red + ) >>> 0; + + this.idxi32[i] = i32; + this.i32idx[i32] = i; + this.i32rgb[i32] = rgb; + }, this); } } @@ -107,18 +107,20 @@ retType = retType || 1; // reduce w/dither + var buf32; if (dithKern) - var out32 = this.dither(img, dithKern, dithSerp); + buf32 = this.dither(img, dithKern, dithSerp); else { - var data = getImageData(img), - buf32 = data.buf32, - len = buf32.length, - out32 = new Uint32Array(len); + var data = getImageData(img); + buf32 = data.buf32; + } - for (var i = 0; i < len; i++) { - var i32 = buf32[i]; - out32[i] = this.nearestColor(i32); - } + var len = buf32.length, + out32 = new Uint32Array(len); + + for (var i = 0; i < len; i++) { + var i32 = buf32[i]; + out32[i] = this.nearestColor(i32); } if (retType == 1) @@ -137,94 +139,94 @@ } }; + // http://www.tannerhelland.com/4660/dithering-eleven-algorithms-source-code/ + var kernels = { + FloydSteinberg: [ + [7 / 16, 1, 0], + [3 / 16, -1, 1], + [5 / 16, 0, 1], + [1 / 16, 1, 1] + ], + FalseFloydSteinberg: [ + [3 / 8, 1, 0], + [3 / 8, 0, 1], + [2 / 8, 1, 1] + ], + Stucki: [ + [8 / 42, 1, 0], + [4 / 42, 2, 0], + [2 / 42, -2, 1], + [4 / 42, -1, 1], + [8 / 42, 0, 1], + [4 / 42, 1, 1], + [2 / 42, 2, 1], + [1 / 42, -2, 2], + [2 / 42, -1, 2], + [4 / 42, 0, 2], + [2 / 42, 1, 2], + [1 / 42, 2, 2] + ], + Atkinson: [ + [1 / 8, 1, 0], + [1 / 8, 2, 0], + [1 / 8, -1, 1], + [1 / 8, 0, 1], + [1 / 8, 1, 1], + [1 / 8, 0, 2] + ], + Jarvis: [ // Jarvis, Judice, and Ninke / JJN? + [7 / 48, 1, 0], + [5 / 48, 2, 0], + [3 / 48, -2, 1], + [5 / 48, -1, 1], + [7 / 48, 0, 1], + [5 / 48, 1, 1], + [3 / 48, 2, 1], + [1 / 48, -2, 2], + [3 / 48, -1, 2], + [5 / 48, 0, 2], + [3 / 48, 1, 2], + [1 / 48, 2, 2] + ], + Burkes: [ + [8 / 32, 1, 0], + [4 / 32, 2, 0], + [2 / 32, -2, 1], + [4 / 32, -1, 1], + [8 / 32, 0, 1], + [4 / 32, 1, 1], + [2 / 32, 2, 1], + ], + Sierra: [ + [5 / 32, 1, 0], + [3 / 32, 2, 0], + [2 / 32, -2, 1], + [4 / 32, -1, 1], + [5 / 32, 0, 1], + [4 / 32, 1, 1], + [2 / 32, 2, 1], + [2 / 32, -1, 2], + [3 / 32, 0, 2], + [2 / 32, 1, 2] + ], + TwoSierra: [ + [4 / 16, 1, 0], + [3 / 16, 2, 0], + [1 / 16, -2, 1], + [2 / 16, -1, 1], + [3 / 16, 0, 1], + [2 / 16, 1, 1], + [1 / 16, 2, 1] + ], + SierraLite: [ + [2 / 4, 1, 0], + [1 / 4, -1, 1], + [1 / 4, 0, 1] + ] + }; + // adapted from http://jsbin.com/iXofIji/2/edit by PAEz RgbQuant.prototype.dither = function(img, kernel, serpentine) { - // http://www.tannerhelland.com/4660/dithering-eleven-algorithms-source-code/ - var kernels = { - FloydSteinberg: [ - [7 / 16, 1, 0], - [3 / 16, -1, 1], - [5 / 16, 0, 1], - [1 / 16, 1, 1] - ], - FalseFloydSteinberg: [ - [3 / 8, 1, 0], - [3 / 8, 0, 1], - [2 / 8, 1, 1] - ], - Stucki: [ - [8 / 42, 1, 0], - [4 / 42, 2, 0], - [2 / 42, -2, 1], - [4 / 42, -1, 1], - [8 / 42, 0, 1], - [4 / 42, 1, 1], - [2 / 42, 2, 1], - [1 / 42, -2, 2], - [2 / 42, -1, 2], - [4 / 42, 0, 2], - [2 / 42, 1, 2], - [1 / 42, 2, 2] - ], - Atkinson: [ - [1 / 8, 1, 0], - [1 / 8, 2, 0], - [1 / 8, -1, 1], - [1 / 8, 0, 1], - [1 / 8, 1, 1], - [1 / 8, 0, 2] - ], - Jarvis: [ // Jarvis, Judice, and Ninke / JJN? - [7 / 48, 1, 0], - [5 / 48, 2, 0], - [3 / 48, -2, 1], - [5 / 48, -1, 1], - [7 / 48, 0, 1], - [5 / 48, 1, 1], - [3 / 48, 2, 1], - [1 / 48, -2, 2], - [3 / 48, -1, 2], - [5 / 48, 0, 2], - [3 / 48, 1, 2], - [1 / 48, 2, 2] - ], - Burkes: [ - [8 / 32, 1, 0], - [4 / 32, 2, 0], - [2 / 32, -2, 1], - [4 / 32, -1, 1], - [8 / 32, 0, 1], - [4 / 32, 1, 1], - [2 / 32, 2, 1], - ], - Sierra: [ - [5 / 32, 1, 0], - [3 / 32, 2, 0], - [2 / 32, -2, 1], - [4 / 32, -1, 1], - [5 / 32, 0, 1], - [4 / 32, 1, 1], - [2 / 32, 2, 1], - [2 / 32, -1, 2], - [3 / 32, 0, 2], - [2 / 32, 1, 2], - ], - TwoSierra: [ - [4 / 16, 1, 0], - [3 / 16, 2, 0], - [1 / 16, -2, 1], - [2 / 16, -1, 1], - [3 / 16, 0, 1], - [2 / 16, 1, 1], - [1 / 16, 2, 1], - ], - SierraLite: [ - [2 / 4, 1, 0], - [1 / 4, -1, 1], - [1 / 4, 0, 1], - ], - }; - if (!kernel || !kernels[kernel]) { throw 'Unknown dithering kernel: ' + kernel; } @@ -240,6 +242,7 @@ var dir = serpentine ? -1 : 1; + console.profile("dither"); for (var y = 0; y < height; y++) { if (serpentine) dir = dir * -1; @@ -251,24 +254,22 @@ var idx = lni + x, i32 = buf32[idx], r1 = (i32 & 0xff), - g1 = (i32 & 0xff00) >> 8, - b1 = (i32 & 0xff0000) >> 16; + g1 = (i32 >>> 8) & 0xff, + b1 = (i32 >>> 16) & 0xff, + a1 = (i32 >>> 24) & 0xff; // Reduced pixel var i32x = this.nearestColor(i32), r2 = (i32x & 0xff), - g2 = (i32x & 0xff00) >> 8, - b2 = (i32x & 0xff0000) >> 16; + g2 = (i32x >>> 8) & 0xff, + b2 = (i32x >>> 16) & 0xff, + a2 = (i32x >>> 24) & 0xff; - buf32[idx] = - (255 << 24) | // alpha - (b2 << 16) | // blue - (g2 << 8) | // green - r2; + buf32[idx] = i32x; // dithering strength if (this.dithDelta) { - var dist = this.colorDist([r1, g1, b1], [r2, g2, b2]); + var dist = this.colorDist([r1, g1, b1, a1], [r2, g2, b2, a2]); if (dist < this.dithDelta) continue; } @@ -276,7 +277,8 @@ // Component distance var er = r1 - r2, eg = g1 - g2, - eb = b1 - b2; + eb = b1 - b2, + ea = a1 - a2; for (var i = (dir == 1 ? 0 : ds.length - 1), end = (dir == 1 ? ds.length : 0); i !== end; i += dir) { var x1 = ds[i][1] * dir, @@ -286,26 +288,32 @@ if (x1 + x >= 0 && x1 + x < width && y1 + y >= 0 && y1 + y < height) { var d = ds[i][0]; - var idx2 = idx + (lni2 + x1); + var idx2 = idx + (lni2 + x1), + i32y = buf32[idx2]; - var r3 = (buf32[idx2] & 0xff), - g3 = (buf32[idx2] & 0xff00) >> 8, - b3 = (buf32[idx2] & 0xff0000) >> 16; + var r3 = (i32y & 0xff), + g3 = (i32y >>> 8) & 0xff, + b3 = (i32y >>> 16) & 0xff, + a3 = (i32y >>> 24) & 0xff; var r4 = Math.max(0, Math.min(255, r3 + er * d)), g4 = Math.max(0, Math.min(255, g3 + eg * d)), - b4 = Math.max(0, Math.min(255, b3 + eb * d)); + b4 = Math.max(0, Math.min(255, b3 + eb * d)), + a4 = Math.max(0, Math.min(255, a3 + ea * d)); - buf32[idx2] = - (255 << 24) | // alpha + buf32[idx2] = ( + (a4 << 24) | // alpha (b4 << 16) | // blue (g4 << 8) | // green - r4; // red + r4 // red + ) >>> 0; + + //if(this.idxi32.indexOf(buf32[idx2]) < 0) throw new Error("no palette entry!"); } } } } - + console.profileEnd("dither"); return buf32; }; @@ -429,8 +437,9 @@ var idxrgb = idxi32.map(function(i32) { return [ (i32 & 0xff), - (i32 & 0xff00) >> 8, - (i32 & 0xff0000) >> 16, + (i32 >>> 8) & 0xff, + (i32 >>> 16) & 0xff, + (i32 >>> 24) & 0xff ]; }); @@ -534,22 +543,21 @@ boxH = this.boxSize[1], area = boxW * boxH, boxes = makeBoxes(width, buf32.length / width, boxW, boxH), - histG = this.histogram, - self = this; + histG = this.histogram; boxes.forEach(function(box) { - var effc = Math.max(Math.round((box.w * box.h) / area) * self.boxPxls, 2), + var effc = Math.max(Math.round((box.w * box.h) / area) * this.boxPxls, 2), histL = {}, col; - iterBox(box, width, function(i) { + this.iterBox(box, width, function(i) { col = buf32[i]; // skip transparent if ((col & 0xff000000) >> 24 == 0) return; // collect hue stats - if (self.hueStats) - self.hueStats.check(col); + if (this.hueStats) + this.hueStats.check(col); if (col in histG) histG[col]++; @@ -560,7 +568,7 @@ else histL[col] = 1; }); - }); + }, this); if (this.hueStats) this.hueStats.inject(histG); @@ -596,9 +604,22 @@ // sync idxrgb & i32idx this.idxi32.forEach(function(i32, i) { - self.idxrgb[i] = self.i32rgb[i32]; - self.i32idx[i32] = i; - }); + this.idxrgb[i] = this.i32rgb[i32]; + this.i32idx[i32] = i; + }, this); + }; + + // iterates @bbox within a parent rect of width @wid; calls @fn, passing index within parent + RgbQuant.prototype.iterBox = function(bbox, wid, fn) { + var b = bbox, + i0 = b.y * wid + b.x, + i1 = (b.y + b.h - 1) * wid + (b.x + b.w - 1), + cnt = 0, incr = wid - b.w + 1, i = i0; + + do { + fn.call(this, i); + i += (++cnt % b.w == 0) ? incr : 1; + } while (i <= i1); }; // TOTRY: use HUSL - http://boronine.com/husl/ @@ -620,8 +641,9 @@ idx, rgb = [ (i32 & 0xff), - (i32 & 0xff00) >> 8, - (i32 & 0xff0000) >> 16, + (i32 >>> 8) & 0xff, + (i32 >>> 16) & 0xff, + (i32 >>> 24) & 0xff ], len = this.idxrgb.length; @@ -657,11 +679,12 @@ HueStats.prototype.check = function checkHue(i32) { if (this.groupsFull == this.numGroups + 1) - this.check = function() {return;}; + this.check = function() {}; var r = (i32 & 0xff), - g = (i32 & 0xff00) >> 8, - b = (i32 & 0xff0000) >> 16, + g = (i32 >>> 8) & 0xff, + b = (i32 >>> 16) & 0xff, + a = (i32 >>> 24) & 0xff, hg = (r == g && g == b) ? -1 : hueGroup(rgb2hsl(r,g,b).h, this.numGroups), gr = this.stats[hg], min = this.minCols; @@ -703,7 +726,8 @@ // Rec. 709 (sRGB) luma coef var Pr = .2126, Pg = .7152, - Pb = .0722; + Pb = .0722, + Pa = 1; // TODO: (igor-bezkrovny) what should be here? // http://alienryderflex.com/hsp.html function rgb2lum(r,g,b) { @@ -716,26 +740,95 @@ var rd = 255, gd = 255, - bd = 255; + bd = 255, + ad = 255; - var euclMax = Math.sqrt(Pr*rd*rd + Pg*gd*gd + Pb*bd*bd); + var euclMax = Math.sqrt(Pr*rd*rd + Pg*gd*gd + Pb*bd*bd + Pa*ad*ad); // perceptual Euclidean color distance function distEuclidean(rgb0, rgb1) { var rd = rgb1[0]-rgb0[0], gd = rgb1[1]-rgb0[1], - bd = rgb1[2]-rgb0[2]; + bd = rgb1[2]-rgb0[2], + ad = rgb1[3]-rgb0[3]; - return Math.sqrt(Pr*rd*rd + Pg*gd*gd + Pb*bd*bd) / euclMax; + return Math.sqrt(Pr*rd*rd + Pg*gd*gd + Pb*bd*bd + Pa*ad*ad) / euclMax; } - var manhMax = Pr*rd + Pg*gd + Pb*bd; + var manhMax = Pr*rd + Pg*gd + Pb*bd + Pa*ad; // perceptual Manhattan color distance function distManhattan(rgb0, rgb1) { var rd = Math.abs(rgb1[0]-rgb0[0]), gd = Math.abs(rgb1[1]-rgb0[1]), - bd = Math.abs(rgb1[2]-rgb0[2]); + bd = Math.abs(rgb1[2]-rgb0[2]), + ad = Math.abs(rgb1[3]-rgb0[3]); + + return (Pr*rd + Pg*gd + Pb*bd + Pa*ad) / manhMax; + } + + /* + Finally, I've found it! After thorough testing and experimentation my conclusions are: + +The correct way is to calculate maximum possible difference between the two colors. +Formulas with any kind of estimated average/typical difference had room for non-linearities. + +I was unable to find correct formula that calculates the distance without blending RGBA colors with backgrounds. + +There is no need to take every possible background color into account, only extremes per R/G/B channel, i.e. for red channel: + +blend both colors with 0 red as background, measure squared difference +blend both colors with max red background, measure squared difference +take higher of the two. +Fortunately blending with "white" and "black" is trivial when you use premultiplied alpha (r = r×a). + +The complete formula is: + max((r?-r?)², (r?-r? - a?+a?)²) + +max((g?-g?)², (g?-g? - a?+a?)²) + +max((b?-b?)², (b?-b? - a?+a?)²) + */ + function colordifference_ch(x, y, alphas) { + // maximum of channel blended on white, and blended on black + // premultiplied alpha and backgrounds 0/1 shorten the formula + var black = x - y, // [-255; 255] + white = black + alphas; // [-255; 255*2] + + return Math.max(black*black, white*white); // [0; 255^2 + (255*2)^2] + } + + //var rgbaMax = (255*255 + (255*2) * (255*2)) * 3; + var rgbaMax = Math.pow(255<<1, 2) * 3; + + function distRGBA(rgb0, rgb1) { +/* + var r1 = rgb0[0], + g1 = rgb0[1], + b1 = rgb0[2], + a1 = rgb0[3]; + + var r2 = rgb1[0], + g2 = rgb1[1], + b2 = rgb1[2], + a2 = rgb1[3]; + + var dr = r1 - r2, + dg = g1 - g2, + db = b1 - b2, + da = a1 - a2; - return (Pr*rd + Pg*gd + Pb*bd) / manhMax; + return (Math.max(dr << 1, dr - da << 1) + + Math.max(dg << 1, dg - da << 1) + + Math.max(db << 1, db - da << 1)) / rgbaMax; + +*/ + var alphas = rgb1[3] - rgb0[3], + dist = colordifference_ch(rgb0[0], rgb1[0], alphas) + + colordifference_ch(rgb0[1], rgb1[1], alphas) + + colordifference_ch(rgb0[2], rgb1[2], alphas); + + if(dist > rgbaMax) { + console.log(dist); + } + + return dist / rgbaMax; } // http://rgb2hsl.nichabi.com/javascript-function.php @@ -765,7 +858,7 @@ return { h: h, s: s, - l: rgb2lum(r,g,b), + l: rgb2lum(r,g,b) }; } @@ -880,7 +973,7 @@ buf8: buf8, buf32: buf32, width: width, - height: height, + height: height }; } @@ -899,19 +992,6 @@ return bxs; } - // iterates @bbox within a parent rect of width @wid; calls @fn, passing index within parent - function iterBox(bbox, wid, fn) { - var b = bbox, - i0 = b.y * wid + b.x, - i1 = (b.y + b.h - 1) * wid + (b.x + b.w - 1), - cnt = 0, incr = wid - b.w + 1, i = i0; - - do { - fn.call(this, i); - i += (++cnt % b.w == 0) ? incr : 1; - } while (i <= i1); - } - // returns array of hash keys sorted by their values function sortedHashKeys(obj, desc) { var keys = []; @@ -932,4 +1012,4 @@ module.exports = RgbQuant; } -}).call(this); \ No newline at end of file +}).call(this); diff --git a/ts/build.cmd b/ts/build.cmd new file mode 100644 index 0000000..e8583c0 --- /dev/null +++ b/ts/build.cmd @@ -0,0 +1,5 @@ +pushd %~dp0 +del /f/q colorQuant.js +del /f/q colorQuant.js.map +tsc colorQuant.ts --sourcemap --out colorQuant.js +popd diff --git a/ts/colorQuant.ts b/ts/colorQuant.ts new file mode 100644 index 0000000..82bbb3a --- /dev/null +++ b/ts/colorQuant.ts @@ -0,0 +1,771 @@ +/* + * Copyright (c) 2015, Leon Sorokin + * All rights reserved. (MIT Licensed) + * + * RgbQuant.js - an image quantization lib + */ + +/// +/// +/// +/// +/// +/// +module ColorQuantization { + + // TODO: make input/output image and input/output palettes with instances of class Point only! + export enum RgbQuantDitheringKernel { + NONE = 0 + } + export class RgbQuant { + // 1 = by global population, 2 = subregion population threshold + private _method : number = 1; + + // desired final palette size + private _colors : number = 256; + + // # of highest-frequency colors to start with for palette reduction + private _initColors : number; + + // color-distance threshold for initial reduction pass + private _initDist = 0.01; + + // subsequent passes threshold + private _distIncr : number = 0.005; + + // palette grouping + private _hueGroups : number = 10; + private _satGroups : number = 10; + private _lumGroups : number = 10; + + // if > 0, enables hues stats and min-color retention per group + private _minHueCols : number; + + // HueStatistics instance + private _hueStats : HueStatistics; + + // subregion partitioning box size + private _boxSize = [64, 64]; + + // number of same pixels required within box for histogram inclusion + private _boxPxls = 2; + + // palette locked indicator + private _palLocked = false; + + // palette sort order +// this.sortPal = ['hue-','lum-','sat-']; + + // dithering/error diffusion kernel name + private _dithKern : RgbQuantDitheringKernel = RgbQuantDitheringKernel.NONE; + + // dither serpentine pattern + private _dithSerp = true; + + // minimum color difference (0-1) needed to dither + private _dithDelta = 0; + + // accumulated histogram + private _histogram = {}; + + // min color occurance count needed to qualify for caching + private _cacheFreq = 10; + + // TODO: make interface for options + constructor(opts : any) { + opts = opts || {}; + + // 1 = by global population, 2 = subregion population threshold + if (typeof opts.method === "number") this._method = opts.method; + + // desired final palette size + if (typeof opts.colors === "number") this._colors = opts.colors; + + // # of highest-frequency colors to start with for palette reduction + this._initColors = this._colors << 2;//opts.initColors || 65536; //4096; + // if > 0, enables hues stats and min-color retention per group + this._minHueCols = this._colors << 2;//opts.minHueCols || 0; + + // dithering/error diffusion kernel name + if (typeof this._dithKern === "number") this._dithKern = opts.dithKern; + + // accumulated histogram + this._histogram = {}; + + // HueStatistics instance + this._hueStats = new HueStatistics(this._hueGroups, this._minHueCols); + } + + // gathers histogram info + public sample(pointBuffer : PointBuffer) { + switch (this._method) { + case 1: + this._colorStats1D(pointBuffer); + break; + case 2: + this._colorStats2D(pointBuffer); + break; + } + } + + // image quantizer + // todo: memoize colors here also + // @retType: 1 - Uint8Array (default), 2 - Indexed array, 3 - Match @img type (unimplemented, todo) + public reduce(pointBuffer : PointBuffer, palette : Palette, dithKern?, dithSerp?) : any { + this._reducePalette(palette, this._colors); + + dithKern = dithKern || this._dithKern; + dithSerp = typeof dithSerp != "undefined" ? dithSerp : this._dithSerp; + + // reduce w/dither + var start = Date.now(); + + //console.profile("__!dither"); + if (dithKern) { + //pointBuffer = this.ditherRiemer(pointBuffer, palette); + + if(typeof window["ditherx"] === "undefined") window["ditherx"] = true; + if( window["ditherx"]) { + pointBuffer = this.ditherFixWithCyclic(pointBuffer, palette, dithKern); + console.log("new (FIXED) dither") + } else { + pointBuffer = this.dither(pointBuffer, palette, dithKern); + console.log("old dither") + } + window["ditherx"] = !window["ditherx"]; + } else { + var pointArray = pointBuffer.getPointArray(); + for (var i = 0, len = pointArray.length; i < len; i++) { + pointArray[ i ].from(palette.nearestColor(pointArray[ i ])); + } + } + var pointArray = pointBuffer.getPointArray(), + len = pointArray.length; + + for (var i = 0; i < len; i++) { + for (var p = 0, found = false; p < palette._paletteArray.length; p++) { + if (palette._paletteArray[p].uint32 === pointArray[i].uint32) { + found = true; + } + } + if (!found) throw new Error("x"); + } + //(console).profileEnd("__!dither"); + console.log("[dither]: " + (Date.now() - start)); + + /* + var pointArray = pointBuffer.getPointArray(), + len : number = pointArray.length; + + for (var i = 0; i < len; i++) { + for(var p = 0, found = false; p < palette._paletteArray.length; p++) { + if(palette._paletteArray[p].uint32 === pointArray[i].uint32) { + found = true; + } + } + if(!found) throw new Error("x"); + //pointArray[ i ].from(palette.nearestColor(pointArray[ i ])); + } + + */ + return pointBuffer; + } + + // adapted from http://jsbin.com/iXofIji/2/edit by PAEz + public dither(pointBuffer : PointBuffer, palette : Palette, kernel) : PointBuffer { + if (!kernel || !kernels[kernel]) { + throw 'Unknown dithering kernel: ' + kernel; + } + + var ds = kernels[kernel]; + + var pointArray = pointBuffer.getPointArray(), + width = pointBuffer.getWidth(), + height = pointBuffer.getHeight(), + dir = 1; + + //(console).profile("dither"); + for (var y = 0; y < height; y++) { + // always serpentine + if (true) dir = dir * -1; + + var lni = y * width, + xStart = dir == 1 ? 0 : width - 1, + xEnd = dir == 1 ? width : -1; + + for (var x = xStart, idx = lni + xStart; x !== xEnd; x += dir, idx += dir) { + // Image pixel + var p1 = pointArray[idx]; + + // Reduced pixel + var point = palette.nearestColor(p1); + + pointArray[idx] = point; + + // dithering strength + if (this._dithDelta) { + var dist = Utils.distEuclidean(p1.rgba, point.rgba); + if (dist < this._dithDelta) + continue; + } + + // Component distance + var er = p1.r - point.r, + eg = p1.g - point.g, + eb = p1.b - point.b, + ea = p1.a - point.a; + + var dStart = dir == 1 ? 0 : ds.length - 1, + dEnd = dir == 1 ? ds.length : -1; + + for (var i = dStart; i !== dEnd; i += dir) { + var x1 = ds[i][1] * dir, + y1 = ds[i][2]; + + var lni2 = y1 * width; + + if (x1 + x >= 0 && x1 + x < width && y1 + y >= 0 && y1 + y < height) { + var d = ds[i][0]; + var idx2 = idx + (lni2 + x1), + p3 = pointArray[idx2]; + + var r4 = Math.max(0, Math.min(255, p3.r + er * d)), + g4 = Math.max(0, Math.min(255, p3.g + eg * d)), + b4 = Math.max(0, Math.min(255, p3.b + eb * d)), + a4 = Math.max(0, Math.min(255, p3.a + ea * d)); + + pointArray[idx2].set(r4, g4, b4, a4); + } + } + } + } + + //(console).profileEnd("dither"); + return pointBuffer; + } + + // adapted from http://jsbin.com/iXofIji/2/edit by PAEz + // TODO: fixed version. it doesn't use image pixels as error storage + public ditherFix(pointBuffer : PointBuffer, palette : Palette, kernel) : PointBuffer { + if (!kernel || !kernels[kernel]) { + throw 'Unknown dithering kernel: ' + kernel; + } + + var ds = kernels[kernel]; + + var pointArray = pointBuffer.getPointArray(), + width = pointBuffer.getWidth(), + height = pointBuffer.getHeight(), + dir = 1, + errors = []; + + for(var i = 0; i < width * height; i++) errors[i] = [0,0,0,0]; + + //(console).profile("dither"); + for (var y = 0; y < height; y++) { + // always serpentine + if (true) dir = dir * -1; + + var lni = y * width, + xStart = dir == 1 ? 0 : width - 1, + xEnd = dir == 1 ? width : -1; + + for (var x = xStart, idx = lni + xStart; x !== xEnd; x += dir, idx += dir) { + // Image pixel + var p1 = pointArray[idx]; + + var r4 = Math.max(0, Math.min(255, p1.r + errors[idx][0])), + g4 = Math.max(0, Math.min(255, p1.g + errors[idx][1])), + b4 = Math.max(0, Math.min(255, p1.b + errors[idx][2])), + a4 = Math.max(0, Math.min(255, p1.a + errors[idx][3])); + + var np = Point.createByRGBA(r4, g4, b4, a4); + + // Reduced pixel + var point = palette.nearestColor(np); + + pointArray[idx].from(point); + + // dithering strength + if (this._dithDelta) { + var dist = Utils.distEuclidean(p1.rgba, point.rgba); + if (dist < this._dithDelta) + continue; + } + + // Component distance + point = np; + var er = p1.r - point.r, + eg = p1.g - point.g, + eb = p1.b - point.b, + ea = p1.a - point.a; + + var dStart = dir == 1 ? 0 : ds.length - 1, + dEnd = dir == 1 ? ds.length : -1; + + for (var i = dStart; i !== dEnd; i += dir) { + var x1 = ds[i][1] * dir, + y1 = ds[i][2]; + + var lni2 = y1 * width; + + if (x1 + x >= 0 && x1 + x < width && y1 + y >= 0 && y1 + y < height) { + var d = ds[i][0]; + var idx2 = idx + (lni2 + x1), + e = errors[idx2]; + + e[0] = e[0] -er * d; + e[1] = e[1] -eg * d; + e[2] = e[2] -eb * d; + e[3] = e[3] -ea * d; + + //pointArray[idx2].set(r4, g4, b4, a4); + } + } + } + } + + //(console).profileEnd("dither"); + return pointBuffer; + } + // adapted from http://jsbin.com/iXofIji/2/edit by PAEz + // TODO: fixed version. it doesn't use image pixels as error storage + public ditherFixWithCyclic(pointBuffer : PointBuffer, palette : Palette, kernel) : PointBuffer { + if (!kernel || !kernels[kernel]) { + throw 'Unknown dithering kernel: ' + kernel; + } + + var ds = kernels[kernel]; + + function fillErrorLine(errorLine : number[][], width : number) { + for(var i = 0; i < width; i++) { + errorLine[i] = [0, 0,0 , 0]; + } + if(errorLine.length > width) { + errorLine.length = width; + } + } + + var pointArray = pointBuffer.getPointArray(), + width = pointBuffer.getWidth(), + height = pointBuffer.getHeight(), + dir = 1, + errorLines = []; + + // initial error lines (number is taken from kernel) + for(var i = 0, maxErrorLines = 1; i < ds.length; i++) { + maxErrorLines = Math.max(maxErrorLines, ds[i][2] + 1); + } + for(var i = 0; i < maxErrorLines; i++) { + fillErrorLine(errorLines[ i ] = [], width); + } + + //(console).profile("dither"); + for (var y = 0; y < height; y++) { + // always serpentine + if (true) dir = dir * -1; + + var lni = y * width, + xStart = dir == 1 ? 0 : width - 1, + xEnd = dir == 1 ? width : -1; + + // cyclic shift with erasing + fillErrorLine(errorLines[ 0 ], width); + errorLines.push(errorLines.shift()); + + var errorLine = errorLines[0]; + + for (var x = xStart, idx = lni + xStart; x !== xEnd; x += dir, idx += dir) { + // Image pixel + var p1 = pointArray[idx], + error = errorLine[x]; + + var r4 = Math.max(0, Math.min(255, p1.r + error[0])), + g4 = Math.max(0, Math.min(255, p1.g + error[1])), + b4 = Math.max(0, Math.min(255, p1.b + error[2])), + a4 = Math.max(0, Math.min(255, p1.a + error[3])); + + var np = Point.createByRGBA(r4, g4, b4, a4); + + // Reduced pixel + var point = palette.nearestColor(np); + + pointArray[idx].from(point); + + // dithering strength + if (this._dithDelta) { + var dist = Utils.distEuclidean(p1.rgba, point.rgba); + if (dist < this._dithDelta) + continue; + } + + // Component distance + point = np; + var er = p1.r - point.r, + eg = p1.g - point.g, + eb = p1.b - point.b, + ea = p1.a - point.a; + + var dStart = dir == 1 ? 0 : ds.length - 1, + dEnd = dir == 1 ? ds.length : -1; + + for (var i = dStart; i !== dEnd; i += dir) { + var x1 = ds[i][1] * dir, + y1 = ds[i][2]; + + if (x1 + x >= 0 && x1 + x < width && y1 + y >= 0 && y1 + y < height) { + var d = ds[i][0], + e = errorLines[y1][x1 + x]; + + e[0] = e[0] -er * d; + e[1] = e[1] -eg * d; + e[2] = e[2] -eb * d; + e[3] = e[3] -ea * d; + } + } + } + } + + //(console).profileEnd("dither"); + return pointBuffer; + } + + // adapted from http://jsbin.com/iXofIji/2/edit by PAEz + /* + public ditherRiemer(pointBuffer : PointBuffer, palette : Palette) : PointBuffer { + var pointArray = pointBuffer.getPointArray(), + width = pointBuffer.getWidth(), + height = pointBuffer.getHeight(), + errorArray = [], + weightsArray = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32]; + + var sum = 0; + for(var i = 0; i < weightsArray.length; i++) { + sum += weightsArray[i]; + } + for(var i = 0; i < weightsArray.length; i++) { + weightsArray[i] /= sum; + } + + for(var i = 0; i < 4; i++) { + errorArray[i] = []; + for(var j = 0; j < weightsArray.length; j++) { + errorArray[i].push(0); + } + } + + function simpleCurve(width, height, callback : (x : number, y : number, index : number) => void) { + for (var y = 0, index = 0; y < height; y++) { + for (var x = 0; x < width; x++, index++) { + callback(x, y, index); + } + + } + } + + simpleCurve(width, height, (x, y, index) => { + var p = pointArray[ index]; + + for(var quadrupletIndex = 0; quadrupletIndex < errorArray.length; quadrupletIndex++) { + var sum = 0; + for(var errorArrayIndex = 0; errorArrayIndex < errorArray.length; errorArrayIndex++) { + sum += errorArray[quadrupletIndex][errorArrayIndex] * weightsArray[errorArrayIndex]; + } + + p.rgba[quadrupletIndex] = Math.max(0, Math.min(255, (p.rgba[quadrupletIndex] + sum) | 0)); + } + + var correctedPoint = Point.createByQuadruplet(p.rgba), + palettePoint = palette.nearestColor(correctedPoint); + + for(var quadrupletIndex = 0; quadrupletIndex < errorArray.length; quadrupletIndex++) { + var componentErrorArray = errorArray[quadrupletIndex]; + componentErrorArray.shift(); + componentErrorArray.push(p.rgba[quadrupletIndex] - palettePoint.rgba[quadrupletIndex]); + } + + p.from(palettePoint); + }); + return pointBuffer; + } + + */ + +/* + public paletteMedianCut () { + var idxi32 = this._getImportanceSortedColorsIDXI32(), + palette : Palette = new Palette(); + + var idxrgb = idxi32.map(function (i32) { + return [ + (i32 & 0xff), + (i32 >>> 8) & 0xff, + (i32 >>> 16) & 0xff, + (i32 >>> 24) & 0xff + ]; + }); + + init(idxrgb); + var arr = get_fixed_size_palette(this._colors); + for(var i = 0; i < arr.length; i++) { + palette._paletteArray.push(Point.createByQuadruplet(arr[i])); + } + console.log("MedianCut"); + return palette; + } +*/ + + // reduces histogram to palette, remaps & memoizes reduced colors + public palette() : Palette { + var idxi32 = this._getImportanceSortedColorsIDXI32(), + palette : Palette = this._buildPalette(idxi32); + + palette.sort(this._hueGroups); + console.log("Original"); + return palette; + /* + var uint32Array = this._palette._paletteArray.map(point => point.uint32); + return tuples ? this._palette._paletteArray : new Uint8Array((new Uint32Array(uint32Array)).buffer); + */ + } + + private _getImportanceSortedColorsIDXI32() { + var sorted = Utils.sortedHashKeys(this._histogram, true); + + if (sorted.length == 0) + throw "Nothing has been sampled, palette cannot be built."; + + switch (this._method) { + case 1: + var initialColorsLimit = Math.min(sorted.length, this._initColors), + last = sorted[initialColorsLimit - 1], + freq = this._histogram[last]; + + var idxi32 = sorted.slice(0, initialColorsLimit); + + // add any cut off colors with same freq as last + var pos = initialColorsLimit, len = sorted.length; + while (pos < len && this._histogram[sorted[pos]] == freq) + idxi32.push(sorted[pos++]); + + // inject min huegroup colors + this._hueStats.inject(idxi32); + + break; + case 2: + var idxi32 = sorted; + break; + } + + // int32-ify values + idxi32 = idxi32.map(function (v) { + return +v; + }); + + return idxi32; + } + + // reduces similar colors from an importance-sorted Uint32 rgba array + private _buildPalette(idxi32) { + // reduce histogram to create initial palette + // build full rgb palette + + var idxrgb = idxi32.map(function (i32) { + return [ + (i32 & 0xff), + (i32 >>> 8) & 0xff, + (i32 >>> 16) & 0xff, + (i32 >>> 24) & 0xff + ]; + }); + /* + var workPalette : Palette = new Palette(), + pointArray = workPalette._paletteArray, + pointIndex, l; + + for(pointIndex = 0, l = idxi32.length; pointIndex < l; pointIndex++) { + pointArray.push(new Point(idxi32[pointIndex])); + } + */ + + var len = idxrgb.length, + palLen = len, + thold = this._initDist; + + // palette already at or below desired length + if (palLen > this._colors) { + while (palLen > this._colors) { + var memDist = []; + + // iterate palette + for (var i = 0; i < len; i++) { + var pxi = idxrgb[i]; + if (!pxi) continue; + + for (var j = i + 1; j < len; j++) { + var pxj = idxrgb[j]; + if (!pxj) continue; + + var dist = Utils.distEuclidean(pxi, pxj); + + if (dist < thold) { + // store index,rgb,dist + memDist.push([j, pxj, dist]); + + idxrgb[j] = null; + palLen--; + } + } + } + + // palette reduction pass + // console.log("palette length: " + palLen); + + // if palette is still much larger than target, increment by larger initDist + thold += (palLen > this._colors * 3) ? this._initDist : this._distIncr; + } + + // if palette is over-reduced, re-add removed colors with largest distances from last round + if (palLen < this._colors) { + // sort descending + Utils.sort.call(memDist, function (a, b) { + return b[2] - a[2]; + }); + + var k = 0; + while (palLen < this._colors && k < memDist.length) { + // re-inject rgb into final palette + idxrgb[memDist[k][0]] = memDist[k][1]; + + palLen++; + k++; + } + } + } + + var palette : Palette = new Palette(); + for (var pointIndex = 0, l = idxrgb.length; pointIndex < l; pointIndex++) { + if (!idxrgb[pointIndex]) continue; + palette._paletteArray.push(Point.createByQuadruplet(idxrgb[pointIndex])); + } + /* + var palette : Palette = new Palette(); + for (pointIndex = 0, l = pointArray.length; pointIndex < l; pointIndex++) { + if (!pointArray[pointIndex]) continue; + palette._paletteArray.push(pointArray[pointIndex]); + } + */ + + return palette; + } + + // TODO: not tested method + private _reducePalette(palette : Palette, colors : number) { + if (palette._paletteArray.length > colors) { + var idxi32 = this._getImportanceSortedColorsIDXI32(); + + // quantize histogram to existing palette + var keep = [], uniqueColors = 0, idx, pruned = false; + + for (var i = 0, len = idxi32.length; i < len; i++) { + // palette length reached, unset all remaining colors (sparse palette) + if (uniqueColors >= colors) { + palette.prunePal(keep); + pruned = true; + break; + } else { + idx = palette.nearestIndex(idxi32[i]); + if (keep.indexOf(idx) < 0) { + keep.push(idx); + uniqueColors++; + } + } + } + + if (!pruned) { + palette.prunePal(keep); + pruned = true; + } + } + } + + // global top-population + private _colorStats1D(pointBuffer : PointBuffer) { + var histG = this._histogram, + pointArray = pointBuffer.getPointArray(), + len = pointArray.length; + + for (var i = 0; i < len; i++) { + var col = pointArray[i].uint32; + + // skip transparent + //if ((col & 0xff000000) >> 24 == 0) continue; + + // collect hue stats + this._hueStats.check(col); + + if (col in histG) + histG[col]++; + else + histG[col] = 1; + } + } + + // population threshold within subregions + // FIXME: this can over-reduce (few/no colors same?), need a way to keep + // important colors that dont ever reach local thresholds (gradients?) + private _colorStats2D(pointBuffer : PointBuffer) { + var width = pointBuffer.getWidth(), + height = pointBuffer.getHeight(), + pointArray = pointBuffer.getPointArray(); + + var boxW = this._boxSize[0], + boxH = this._boxSize[1], + area = boxW * boxH, + boxes = Utils.makeBoxes(width, height, boxW, boxH), + histG = this._histogram; + + boxes.forEach(function (box) { + var effc = Math.max(Math.round((box.w * box.h) / area) * this._boxPxls, 2), + histL = {}, + col; + + this._iterBox(box, width, function (i) { + col = pointArray[i].uint32; + + // skip transparent + //if ((col & 0xff000000) >> 24 == 0) return; + + // collect hue stats + this._hueStats.check(col); + + if (col in histG) + histG[col]++; + else if (col in histL) { + if (++histL[col] >= effc) + histG[col] = histL[col]; + } + else + histL[col] = 1; + }); + }, this); + + // inject min huegroup colors + this._hueStats.inject(histG); + } + + // iterates @bbox within a parent rect of width @wid; calls @fn, passing index within parent + private _iterBox(bbox, wid, fn) { + var b = bbox, + i0 = b.y * wid + b.x, + i1 = (b.y + b.h - 1) * wid + (b.x + b.w - 1), + cnt = 0, incr = wid - b.w + 1, i = i0; + + do { + fn.call(this, i); + i += (++cnt % b.w == 0) ? incr : 1; + } while (i <= i1); + } + } + +} diff --git a/ts/dither.ts b/ts/dither.ts new file mode 100644 index 0000000..74ee792 --- /dev/null +++ b/ts/dither.ts @@ -0,0 +1,90 @@ +module ColorQuantization { + + // http://www.tannerhelland.com/4660/dithering-eleven-algorithms-source-code/ + export var kernels = { + FloydSteinberg : [ + [ 7 / 16, 1, 0 ], + [ 3 / 16, -1, 1 ], + [ 5 / 16, 0, 1 ], + [ 1 / 16, 1, 1 ] + ], + FalseFloydSteinberg: [ + [ 3 / 8, 1, 0 ], + [ 3 / 8, 0, 1 ], + [ 2 / 8, 1, 1 ] + ], + Stucki : [ + [ 8 / 42, 1, 0 ], + [ 4 / 42, 2, 0 ], + [ 2 / 42, -2, 1 ], + [ 4 / 42, -1, 1 ], + [ 8 / 42, 0, 1 ], + [ 4 / 42, 1, 1 ], + [ 2 / 42, 2, 1 ], + [ 1 / 42, -2, 2 ], + [ 2 / 42, -1, 2 ], + [ 4 / 42, 0, 2 ], + [ 2 / 42, 1, 2 ], + [ 1 / 42, 2, 2 ] + ], + Atkinson : [ + [ 1 / 8, 1, 0 ], + [ 1 / 8, 2, 0 ], + [ 1 / 8, -1, 1 ], + [ 1 / 8, 0, 1 ], + [ 1 / 8, 1, 1 ], + [ 1 / 8, 0, 2 ] + ], + Jarvis : [ // Jarvis, Judice, and Ninke / JJN? + [ 7 / 48, 1, 0 ], + [ 5 / 48, 2, 0 ], + [ 3 / 48, -2, 1 ], + [ 5 / 48, -1, 1 ], + [ 7 / 48, 0, 1 ], + [ 5 / 48, 1, 1 ], + [ 3 / 48, 2, 1 ], + [ 1 / 48, -2, 2 ], + [ 3 / 48, -1, 2 ], + [ 5 / 48, 0, 2 ], + [ 3 / 48, 1, 2 ], + [ 1 / 48, 2, 2 ] + ], + Burkes : [ + [ 8 / 32, 1, 0 ], + [ 4 / 32, 2, 0 ], + [ 2 / 32, -2, 1 ], + [ 4 / 32, -1, 1 ], + [ 8 / 32, 0, 1 ], + [ 4 / 32, 1, 1 ], + [ 2 / 32, 2, 1 ], + ], + Sierra : [ + [ 5 / 32, 1, 0 ], + [ 3 / 32, 2, 0 ], + [ 2 / 32, -2, 1 ], + [ 4 / 32, -1, 1 ], + [ 5 / 32, 0, 1 ], + [ 4 / 32, 1, 1 ], + [ 2 / 32, 2, 1 ], + [ 2 / 32, -1, 2 ], + [ 3 / 32, 0, 2 ], + [ 2 / 32, 1, 2 ] + ], + TwoSierra : [ + [ 4 / 16, 1, 0 ], + [ 3 / 16, 2, 0 ], + [ 1 / 16, -2, 1 ], + [ 2 / 16, -1, 1 ], + [ 3 / 16, 0, 1 ], + [ 2 / 16, 1, 1 ], + [ 1 / 16, 2, 1 ] + ], + SierraLite : [ + [ 2 / 4, 1, 0 ], + [ 1 / 4, -1, 1 ], + [ 1 / 4, 0, 1 ] + ] + }; + + +} diff --git a/ts/hueStatistics.ts b/ts/hueStatistics.ts new file mode 100644 index 0000000..ef50ca1 --- /dev/null +++ b/ts/hueStatistics.ts @@ -0,0 +1,76 @@ +/// +module ColorQuantization { + + class HueGroup { + public num : number = 0; + public cols : number[] = []; + } + + export class HueStatistics { + private _numGroups; + private _minCols; + private _stats; + private _groupsFull; + + constructor(numGroups : number, minCols : number) { + this._numGroups = numGroups; + this._minCols = minCols; + this._stats = []; + + for (var i = 0; i <= numGroups; i++) { + this._stats[i] = new HueGroup(); + } + + this._groupsFull = 0; + } + + public check(i32) { + if (this._groupsFull == this._numGroups + 1) { + this.check = function () { + }; + } + + var r = (i32 & 0xff), + g = (i32 >>> 8) & 0xff, + b = (i32 >>> 16) & 0xff, + a = (i32 >>> 24) & 0xff, + hg = (r == g && g == b) ? 0 : 1 + Utils.hueGroup(Utils.rgb2hsl(r, g, b).h, this._numGroups), + gr : HueGroup = this._stats[ hg ], + min = this._minCols; + + gr.num++; + + if (gr.num > min) + return; + if (gr.num == min) + this._groupsFull++; + + if (gr.num <= min) + this._stats[ hg ].cols.push(i32); + } + + public inject(histG) { + for (var i = 0; i <= this._numGroups; i++) { + if (this._stats[ i ].num <= this._minCols) { + switch (Utils.typeOf(histG)) { + case "Array": + this._stats[ i ].cols.forEach(function (col) { + if (histG.indexOf(col) == -1) + histG.push(col); + }); + break; + case "Object": + this._stats[ i ].cols.forEach(function (col) { + if (!histG[ col ]) + histG[ col ] = 1; + else + histG[ col ]++; + }); + break; + } + } + } + } + } + +} diff --git a/ts/node-test/_build_demo.cmd b/ts/node-test/_build_demo.cmd new file mode 100644 index 0000000..55bef50 --- /dev/null +++ b/ts/node-test/_build_demo.cmd @@ -0,0 +1,5 @@ +pushd %~dp0 +del /f/q _demo.js +del /f/q _demo.js.map +tsc _demo.ts --sourcemap --out _demo.js +popd diff --git a/ts/node-test/_demo.ts b/ts/node-test/_demo.ts new file mode 100644 index 0000000..978b6d8 --- /dev/null +++ b/ts/node-test/_demo.ts @@ -0,0 +1,29 @@ +/// + +var width = 32, + height = 32, + imageArray = []; + +for(var i = 0; i < width * height * 4; i++) { + imageArray[i] = (Math.random() * 256) | 0; +} + +for(var i = 0; i < 100; i++) { + var start = Date.now(); + + var cq = new ColorQuantization.RgbQuant({ + colors : 1024, + dithKern : "SierraLite" + }); + + var pointBuffer = new ColorQuantization.PointBuffer(); + pointBuffer.importArray(imageArray, width, height); + + cq.sample(pointBuffer); + + var pal8 = cq.palette(); + + var img8 = cq.reduce(pointBuffer, pal8).exportUint8Array(); + + console.log(i + ": " + (Date.now() - start)); +} diff --git a/ts/node-test/_test.cmd b/ts/node-test/_test.cmd new file mode 100644 index 0000000..a075489 --- /dev/null +++ b/ts/node-test/_test.cmd @@ -0,0 +1,3 @@ +del /q/f *.asm +del /q/f *.cfg +node --trace-hydrogen --trace-phase=Z --trace-deopt --code-comments --hydrogen-track-positions --redirect-code-traces --print_deopt_stress _demo.js \ No newline at end of file diff --git a/ts/palette.ts b/ts/palette.ts new file mode 100644 index 0000000..1692b55 --- /dev/null +++ b/ts/palette.ts @@ -0,0 +1,115 @@ +/// +// TODO: make paletteArray via pointBuffer, so, export will be available via pointBuffer.exportXXX +module ColorQuantization { + + export class Palette { + public _paletteArray : Point[] = []; + private _i32idx : { [ key: string ] : number } = {}; + + // TOTRY: use HUSL - http://boronine.com/husl/ + public nearestColor(point : Point) : Point { + return this._paletteArray[this.nearestIndex_Point(point) | 0]; + } + + // TOTRY: use HUSL - http://boronine.com/husl/ + public nearestIndex(i32) { + var idx : number = this._nearestPointFromCache("" + i32); + if (idx >= 0) return idx; + + var min = 1000, + rgb = [ + (i32 & 0xff), + (i32 >>> 8) & 0xff, + (i32 >>> 16) & 0xff, + (i32 >>> 24) & 0xff + ], + len = this._paletteArray.length; + + idx = 0; + for (var i = 0; i < len; i++) { + var dist = Utils.distEuclidean(rgb, this._paletteArray[i].rgba); + + if (dist < min) { + min = dist; + idx = i; + } + } + + this._i32idx[i32] = idx; + return idx; + } + + private _nearestPointFromCache(key) { + return typeof this._i32idx[key] === "number" ? this._i32idx[key] : -1; + } + + public nearestIndex_Point(point : Point) : number { + var idx : number = this._nearestPointFromCache("" + point.uint32); + if (idx >= 0) return idx; + + var minimalDistance : number = 1000.0; + + for (var idx = 0, i = 0, l = this._paletteArray.length; i < l; i++) { + var distance = Utils.distEuclidean(point.rgba, this._paletteArray[i].rgba); + + if (distance < minimalDistance) { + minimalDistance = distance; + idx = i; + } + } + + this._i32idx[point.uint32] = idx; + return idx; + } + + // TODO: check usage, not tested! + public prunePal(keep : number[]) { + var point : Point; + + for (var j = 0; j < this._paletteArray.length; j++) { + if (keep.indexOf(j) < 0) { + this._paletteArray[j] = null; + } + } + + // compact + var compactedPaletteArray : Point[] = []; + + for (var j = 0, i = 0; j < this._paletteArray.length; j++) { + if (this._paletteArray[j]) { + compactedPaletteArray[i] = this._paletteArray[j]; + i++; + } + } + + this._paletteArray = compactedPaletteArray; + } + + // TODO: group very low lum and very high lum colors + // TODO: pass custom sort order + // TODO: sort criteria function should be placed to HueStats class + public sort(hueGroups : number) { + this._paletteArray.sort((a : Point, b : Point) => { + var rgbA = a.rgba, + rgbB = b.rgba; + + var hslA = Utils.rgb2hsl(rgbA[ 0 ], rgbA[ 1 ], rgbA[ 2 ]), + hslB = Utils.rgb2hsl(rgbB[ 0 ], rgbB[ 1 ], rgbB[ 2 ]); + + // sort all grays + whites together + var hueA = (rgbA[ 0 ] == rgbA[ 1 ] && rgbA[ 1 ] == rgbA[ 2 ]) ? 0 : 1 + Utils.hueGroup(hslA.h, hueGroups); + var hueB = (rgbB[ 0 ] == rgbB[ 1 ] && rgbB[ 1 ] == rgbB[ 2 ]) ? 0 : 1 + Utils.hueGroup(hslB.h, hueGroups); + + var hueDiff = hueB - hueA; + if (hueDiff) return -hueDiff; + + var lumDiff = Utils.lumGroup(+hslB.l.toFixed(2)) - Utils.lumGroup(+hslA.l.toFixed(2)); + if (lumDiff) return -lumDiff; + + var satDiff = Utils.satGroup(+hslB.s.toFixed(2)) - Utils.satGroup(+hslA.s.toFixed(2)); + if (satDiff) return -satDiff; + }); + } + + } +} diff --git a/ts/point.ts b/ts/point.ts new file mode 100644 index 0000000..49b6d1d --- /dev/null +++ b/ts/point.ts @@ -0,0 +1,126 @@ +module ColorQuantization { + + /** + * v8 optimized class + * 1) "constructor" should have initialization with worst types + * 2) "set" should have |0 / >>> 0 + */ + export class Point { + public r : number; + public g : number; + public b : number; + public a : number; + public uint32 : number; + public rgba : number[]; // TODO: better name is quadruplet or quad may be? + + static createByQuadruplet(quadruplet : number[]) : Point { + var point : Point = new Point(); + + point.r = quadruplet[0] | 0; + point.g = quadruplet[1] | 0; + point.b = quadruplet[2] | 0; + point.a = quadruplet[3] | 0; + point._loadUINT32(); + point._loadQuadruplet(); + + return point; + } + + static createByRGBA(red : number, green : number, blue : number, alpha : number): Point { + var point : Point = new Point(); + + point.r = red | 0; + point.g = green | 0; + point.b = blue | 0; + point.a = alpha | 0; + point._loadUINT32(); + point._loadQuadruplet(); + + return point; + } + + static createByUint32(uint32 : number) : Point { + var point : Point = new Point(); + + point.uint32 = uint32 >>> 0; + point._loadRGBA(); + point._loadQuadruplet(); + + return point; + } + + constructor(/*...args : number[]*/) { + this.r = this.g = this.b = this.a = 0; + this.rgba = [ this.r , this.g , this.b , this.a ]; + this.uint32 = -1 >>> 0; + //this.set(...args); + } + + public from(point : Point) { + this.r = point.r; + this.g = point.g; + this.b = point.b; + this.a = point.a; + this.uint32 = point.uint32; + this.rgba = point.rgba.slice(0); + } + + public set(...args : number[]) { + switch(args.length) { + case 1: + if(typeof args[0] === "number") { + this.uint32 = args[0] >>> 0; + this._loadRGBA(); + } else if(Utils.typeOf(args[0]) === "Array") { + this.r = args[0][0] | 0; + this.g = args[0][1] | 0; + this.b = args[0][2] | 0; + this.a = args[0][3] | 0; + this._loadUINT32(); + } else { + throw new Error("Point.constructor/set: unsupported single parameter"); + } + break; + + case 4: + this.r = args[0] | 0; + this.g = args[1] | 0; + this.b = args[2] | 0; + this.a = args[3] | 0; + this._loadUINT32(); + break; + + default: + throw new Error("Point.constructor/set should have parameter/s"); + } + + this._loadQuadruplet(); + } + + private _loadUINT32() { + this.uint32 = ( + (this.a << 24) | // alpha + (this.b << 16) | // blue + (this.g << 8) | // green + this.r // red + ) >>> 0; + } + + private _loadRGBA() { + this.r = this.uint32 & 0xff; + this.g = (this.uint32 >>> 8) & 0xff; + this.b = (this.uint32 >>> 16) & 0xff; + this.a = (this.uint32 >>> 24) & 0xff; + } + + private _loadQuadruplet() { + this.rgba = [ + this.r, + this.g, + this.b, + this.a + ]; + } + } + +} diff --git a/ts/pointBuffer.ts b/ts/pointBuffer.ts new file mode 100644 index 0000000..bdd746c --- /dev/null +++ b/ts/pointBuffer.ts @@ -0,0 +1,133 @@ +/// +module ColorQuantization { + + // TODO: http://www.javascripture.com/Uint8ClampedArray + // TODO: Uint8ClampedArray is better than Uint8Array to avoid checking for out of bounds + // TODO: check performance (it seems identical) http://jsperf.com/uint8-typed-array-vs-imagedata/4 + /* + + TODO: Examples: + + var x = new Uint8ClampedArray([17, -45.3]); + console.log(x[0]); // 17 + console.log(x[1]); // 0 + console.log(x.length); // 2 + + var x = new Uint8Array([17, -45.3]); + console.log(x[0]); // 17 + console.log(x[1]); // 211 + console.log(x.length); // 2 + + */ + + export class PointBuffer { + private _pointArray : Point[]; + private _width : number; + private _height : number; + + constructor() { + } + + public getWidth() : number { + return this._width; + } + + public getHeight() : number { + return this._height; + } + + public getPointArray() : Point[] { + return this._pointArray; + } + + public importPointBuffer(pointBuffer : PointBuffer) : void { + this._width = pointBuffer._width; + this._height = pointBuffer._height; + + this._pointArray = []; + for(var i = 0, l = pointBuffer._pointArray.length; i < l; i++) { + this._pointArray[i] = Point.createByUint32(pointBuffer._pointArray[i].uint32 | 0); // "| 0" is added for v8 optimization + } + } + + public importHTMLImageElement(img : HTMLImageElement) : void { + var width = img.naturalWidth, + height = img.naturalHeight; + + var canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + + var ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0, width,height, 0, 0, width, height); + + this.importHTMLCanvasElement(canvas); + } + + public importHTMLCanvasElement(canvas : HTMLCanvasElement) : void { + var width = canvas.width, + height = canvas.height; + + var ctx = canvas.getContext("2d"), + imgData = ctx.getImageData(0, 0, width, height); + + this.importImageData(imgData); + } + + public importNodeCanvas(canvas : any) : void { + this.importHTMLCanvasElement(canvas); + } + + public importImageData(imageData : ImageData) : void { + var width = imageData.width, + height = imageData.height; + + this.importCanvasPixelArray(imageData.data, width, height); +/* + var buf8; + if (Utils.typeOf(imageData.data) == "CanvasPixelArray") + buf8 = new Uint8Array(imageData.data); + else + buf8 = imageData.data; + + this.importUint32Array(new Uint32Array(buf8.buffer), width, height); +*/ + } + + public importArray(data : number[], width : number, height : number) : void { + var uint8array = new Uint8Array(data); + this.importUint32Array(new Uint32Array(uint8array.buffer), width, height); + } + + public importCanvasPixelArray(data : any, width : number, height : number) { + this.importArray(data, width, height); + } + + public importUint32Array(uint32array : Uint32Array, width : number, height : number) : void { + this._width = width; + this._height = height; + + this._pointArray = [];//new Array(uint32array.length); + for(var i = 0, l = uint32array.length; i < l; i++) { + this._pointArray[i] = Point.createByUint32(uint32array[i] | 0); // "| 0" is added for v8 optimization + } + } + + public exportUint32Array() : Uint32Array { + var l = this._pointArray.length, + uint32Array = new Uint32Array(l); + + for(var i = 0; i < l; i++) { + uint32Array[i] = this._pointArray[i].uint32; + } + + return uint32Array; + } + + public exportUint8Array() : Uint8Array { + return new Uint8Array(this.exportUint32Array().buffer); + } + + } + +} diff --git a/ts/utils.ts b/ts/utils.ts new file mode 100644 index 0000000..eee548a --- /dev/null +++ b/ts/utils.ts @@ -0,0 +1,247 @@ +module ColorQuantization.Utils { + + // Rec. 709 (sRGB) luma coef + var Pr = .2126, + Pg = .7152, + Pb = .0722, + Pa = 1; // TODO: (igor-bezkrovny) what should be here? + + // test if js engine's Array#sort implementation is stable + function isArrSortStable() { + var str = "abcdefghijklmnopqrstuvwxyz"; + + return "xyzvwtursopqmnklhijfgdeabc" == str.split("").sort(function (a, b) { + return ~~(str.indexOf(b) / 2.3) - ~~(str.indexOf(a) / 2.3); + }).join(""); + } + + // TODO: move to separate file like "utils.ts" - it is used by colorQuant too! + export function typeOf(val) { + return Object.prototype.toString.call(val).slice(8, -1); + } + + // http://alienryderflex.com/hsp.html + export function rgb2lum(r, g, b) { + return Math.sqrt( + Pr * r * r + + Pg * g * g + + Pb * b * b + ); + } + + // http://rgb2hsl.nichabi.com/javascript-function.php + export function rgb2hsl(r, g, b) { + var max, min, h, s, l, d; + r /= 255; + g /= 255; + b /= 255; + max = Math.max(r, g, b); + min = Math.min(r, g, b); + l = (max + min) / 2; + if (max == min) { + h = s = 0; + } else { + d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + h = (r - g) / d + 4; + break + } + h /= 6; + } +// h = Math.floor(h * 360) +// s = Math.floor(s * 100) +// l = Math.floor(l * 100) + return { + h: h, + s: s, + l: rgb2lum(r, g, b) + }; + } + + export function hueGroup(hue, segs) { + var seg = 1 / segs, + haf = seg / 2; + + if (hue >= 1 - haf || hue <= haf) + return 0; + + for (var i = 1; i < segs; i++) { + var mid = i * seg; + if (hue >= mid - haf && hue <= mid + haf) + return i; + } + } + + export function satGroup(sat) { + return sat; + } + + export function lumGroup(lum) { + return lum; + } + + export var sort = isArrSortStable() ? Array.prototype.sort : stableSort; + + // must be used via stableSort.call(arr, fn) + export function stableSort(fn) { + var type = typeOf(this[ 0 ]); + + if (type == "Number" || type == "String") { + var ord = {}, len = this.length, val; + + for (var i = 0; i < len; i++) { + val = this[ i ]; + if (ord[ val ] || ord[ val ] === 0) continue; + ord[ val ] = i; + } + + return this.sort(function (a, b) { + return fn(a, b) || ord[ a ] - ord[ b ]; + }); + } + else { + var ord2 = this.map(function (v) { + return v + }); + + return this.sort(function (a, b) { + return fn(a, b) || ord2.indexOf(a) - ord2.indexOf(b); + }); + } + } + + // partitions a rect of wid x hgt into + // array of bboxes of w0 x h0 (or less) + export function makeBoxes(wid, hgt, w0, h0) { + var wnum = ~~(wid / w0), wrem = wid % w0, + hnum = ~~(hgt / h0), hrem = hgt % h0, + xend = wid - wrem, yend = hgt - hrem; + + var bxs = []; + for (var y = 0; y < hgt; y += h0) + for (var x = 0; x < wid; x += w0) + bxs.push({x: x, y: y, w: (x == xend ? wrem : w0), h: (y == yend ? hrem : h0)}); + + return bxs; + } + + // returns array of hash keys sorted by their values + export function sortedHashKeys(obj, desc) { + var keys = Object.keys(obj); + if(desc) { + return sort.call(keys, function (a, b) { + return obj[ b ] - obj[ a ]; + }); + } else { + return sort.call(keys, function (a, b) { + return obj[ a ] - obj[ b ]; + }); + } + } + + var rd = 255, + gd = 255, + bd = 255, + ad = 255; + + var euclMax = Math.sqrt(Pr * rd * rd + Pg * gd * gd + Pb * bd * bd + Pa * ad * ad); + // perceptual Euclidean color distance + export function distEuclidean(rgb0, rgb1) { + var rd = rgb1[ 0 ] - rgb0[ 0 ], + gd = rgb1[ 1 ] - rgb0[ 1 ], + bd = rgb1[ 2 ] - rgb0[ 2 ], + ad = rgb1[ 3 ] - rgb0[ 3 ]; + + return Math.sqrt(Pr * rd * rd + Pg * gd * gd + Pb * bd * bd + Pa * ad * ad) / euclMax; + } + +/* + var manhMax = Pr * rd + Pg * gd + Pb * bd + Pa * ad; + // perceptual Manhattan color distance + function distManhattan(rgb0, rgb1) { + var rd = Math.abs(rgb1[ 0 ] - rgb0[ 0 ]), + gd = Math.abs(rgb1[ 1 ] - rgb0[ 1 ]), + bd = Math.abs(rgb1[ 2 ] - rgb0[ 2 ]), + ad = Math.abs(rgb1[ 3 ] - rgb0[ 3 ]); + + return (Pr * rd + Pg * gd + Pb * bd + Pa * ad) / manhMax; + } +*/ + + /* + Finally, I've found it! After thorough testing and experimentation my conclusions are: + + The correct way is to calculate maximum possible difference between the two colors. + Formulas with any kind of estimated average/typical difference had room for non-linearities. + + I was unable to find correct formula that calculates the distance without blending RGBA colors with backgrounds. + + There is no need to take every possible background color into account, only extremes per R/G/B channel, i.e. for red channel: + + blend both colors with 0 red as background, measure squared difference + blend both colors with max red background, measure squared difference + take higher of the two. + Fortunately blending with "white" and "black" is trivial when you use premultiplied alpha (r = r×a). + + The complete formula is: + max((r?-r?)², (r?-r? - a?+a?)²) + + max((g?-g?)², (g?-g? - a?+a?)²) + + max((b?-b?)², (b?-b? - a?+a?)²) + */ +/* + function colordifference_ch(x, y, alphas) { + // maximum of channel blended on white, and blended on black + // premultiplied alpha and backgrounds 0/1 shorten the formula + var black = x - y, // [-255; 255] + white = black + alphas; // [-255; 255*2] + + return Math.max(black * black, white * white); // [0; 255^2 + (255*2)^2] + } + + //var rgbaMax = (255*255 + (255*2) * (255*2)) * 3; + var rgbaMax = Math.pow(255 << 1, 2) * 3; + + function distRGBA(rgb0, rgb1) { + /!* + var r1 = rgb0[0], + g1 = rgb0[1], + b1 = rgb0[2], + a1 = rgb0[3]; + + var r2 = rgb1[0], + g2 = rgb1[1], + b2 = rgb1[2], + a2 = rgb1[3]; + + var dr = r1 - r2, + dg = g1 - g2, + db = b1 - b2, + da = a1 - a2; + + return (Math.max(dr << 1, dr - da << 1) + + Math.max(dg << 1, dg - da << 1) + + Math.max(db << 1, db - da << 1)) / rgbaMax; + + *!/ + var alphas = rgb1[ 3 ] - rgb0[ 3 ], + dist = colordifference_ch(rgb0[ 0 ], rgb1[ 0 ], alphas) + + colordifference_ch(rgb0[ 1 ], rgb1[ 1 ], alphas) + + colordifference_ch(rgb0[ 2 ], rgb1[ 2 ], alphas); + + if (dist > rgbaMax) { + console.log(dist); + } + + return dist / rgbaMax; + } +*/ + +}