├── .gitignore ├── .npmignore ├── LICENSE.md ├── README.md ├── atcq.js ├── demo ├── baboon.png ├── extract.js ├── util │ ├── cie2000.js │ ├── cie94.js │ └── lab.js └── visual.js ├── images ├── 2020.05.01-11.43.00-atcq-lab-ciede2000-s0.2-disconnects-q16.png ├── 2020.05.01-11.43.25-atcq-lab-ciede2000-s0.2-disconnects-q16.png ├── 2020.05.01-11.43.40-wuquant-ciede2000-q16.png ├── 2020.05.01-11.43.45-rgbquant-ciede2000-q16.png ├── 2020.05.01-11.43.50-neuquant-ciede2000-q16.png ├── 2020.05.01-11.44.48-neuquant-ciede2000-q6.png ├── 2020.05.01-11.44.52-rgbquant-ciede2000-q6.png ├── 2020.05.01-11.45.00-wuquant-ciede2000-q6.png ├── 2020.05.01-11.45.23-atcq-lab-ciede2000-s0.2-disconnects-max64-q6.png ├── 2020.05.01-11.46.21-atcq-lab-ciede2000-s0.75-disconnects-max64-q6.png ├── 2020.05.01-11.46.45-atcq-lab-ciede2000-s0.75-disconnects-max64-q6.png ├── 2020.05.01-11.47.07-atcq-lab-ciede2000-s0.2-disconnects-max64-q6.png ├── 2020.05.01-12.02.20-atcq-lab-ciede2000-a0.75-disconnects-max16-q16.png └── 2020.05.01-12.02.49-atcq-lab-ciede2000-a0.75-disconnects-max16-q6.png ├── lib ├── AntNode.js ├── ClusterNode.js ├── Node.js ├── SupportNode.js ├── Types.js ├── mst.js └── util.js ├── package-lock.json ├── package.json └── test ├── test-black.js └── test-high-dimension.js /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | *.log 4 | .DS_Store 5 | bundle.js 6 | .cache/ 7 | dist/ -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | *.log 4 | .DS_Store 5 | bundle.js 6 | test 7 | test.js 8 | demo/ 9 | .npmignore 10 | LICENSE.md 11 | .cache/ 12 | dist/ -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2020 Matt DesLauriers 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 18 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 19 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 20 | OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # atcq 2 | 3 | [![experimental](http://badges.github.io/stability-badges/dist/experimental.svg)](http://github.com/badges/stability-badges) 4 | 5 | An implementation of Ant-Tree Color Quantization (ATCQ), described by Pérez-Delgado in various papers including: [[1]](https://ieeexplore.ieee.org/document/8815696), [[2]](https://www.sciencedirect.com/science/article/abs/pii/S1568494615005086). 6 | 7 | 8 | 9 | > 10 | 11 | Resulting 16 color palette, sorted and weighted by size: 12 | 13 | ![palette](./images/2020.05.01-11.43.00-atcq-lab-ciede2000-s0.2-disconnects-q16.png) 14 | 15 | With post-processing the data, we can further reduce to 6 disparate colors: 16 | 17 | ![palette](./images/2020.05.01-11.47.07-atcq-lab-ciede2000-s0.2-disconnects-max64-q6.png) 18 | 19 | **NOTE:** This library is not yet fully documented or tested yet, and probably not yet suited for production. 20 | 21 | ## Differences from other Algorithms 22 | 23 | The ATCQ algorithm runs iteratively, which means the colours it outputs get progressively more representative of the input pixels as the algorithm runs. It may also produce different results each time it runs, as it uses randomness to decide on certain operations while it runs. 24 | 25 | This is a clustering-based algorithm, unlike many other popular quantization algorithms, which tend to be splitting-based. 26 | 27 | According to the research papers by Pérez-Delgado: 28 | 29 | > This method was compared to other well-known color quantization methods, obtaining better images than Octree, Median-cut and Variance-based methods. 30 | 31 | This competes with Xiaolin Wu's "Greedy orthogonal bi-partitioning method" (GOBP) in regard to mean squared error, sometimes producing better results, and there are various ways to improve the ATCQ and combine it with the ideas of GOBP to get the 'best of both worlds' (as in [[1]](https://ieeexplore.ieee.org/document/8815696)). 32 | 33 | However, the bare-bones ATCQ algorithm is also very slow and memory-intensive, ideally suited for situations where: 34 | 35 | - You are dealing with a small input image, e.g. 512x512px 36 | - You are dealing with a small output palette, e.g. 1-256 colours 37 | - It is acceptable to have the algorithm run iteratively, i.e. in the background 38 | - You want the weight of each colour in the reduced palette 39 | - You want a little more control and flexibility over the quantization than what some other algorithms offer 40 | 41 | I am using it for small color palette generation, see [Comparisons](#Comparisons). 42 | 43 | If you are just interested in a simple quantizer, you may want to check out [image-q](https://github.com/ibezkrovnyi/image-quantization) or [quantize](https://www.npmjs.com/package/quantize), which are both fast and suitable for most situations. 44 | 45 | ## Quick Start 46 | 47 | ```js 48 | const ATCQ = require('atcq'); 49 | 50 | const pixels = /* ... rgb pixels ... */ 51 | const palette = ATCQ.quantizeSync(pixels, { 52 | maxColors: 32 53 | }); 54 | 55 | // array of 32 quantized RGB colors 56 | console.log(palette); 57 | ``` 58 | 59 | Here `pixels` can be a flat RGBA array, an array of `[ r, g, b ]` pixels, or an ImageData object. 60 | 61 | ## Async Example 62 | 63 | Or, an async example, that uses an interval to reduce blocking the thread: 64 | 65 | ```js 66 | const ATCQ = require('atcq'); 67 | 68 | (async () => { 69 | const pixels = /* ... rgb pixels ... */ 70 | const palette = await ATCQ.quantizeAsync(pixels, { 71 | maxColors: 32, 72 | // Max number of pixels to process in a single step 73 | windowSize: 1024 * 50 74 | }); 75 | 76 | // array of 32 quantized RGB colors 77 | console.log(palette); 78 | })(); 79 | ``` 80 | 81 | ## Weighted Palettes 82 | 83 | A more advanced example, producing a weighted and sorted palette, plus a further reduced 'disparate' palette. 84 | 85 | ```js 86 | const actq = ATCQ({ 87 | maxColors: 32, 88 | progress: (p) => console.log('Progress:', p) 89 | }); 90 | 91 | // add data into system 92 | actq.addData(pixels); 93 | 94 | (async () => { 95 | // run quantizer 96 | // while its running you can visualize the palettes etc... 97 | await actq.quantizeAsync(); 98 | 99 | const palette = actq.getWeightedPalette(); 100 | 101 | console.log(palette[0]); 102 | // { color: [ r, g, b ], weight: N } 103 | 104 | // You can get a 'disparate' palette like so: 105 | const minColors = 5; 106 | const bestColors = actq.getWeightedPalette(minColors); 107 | })(); 108 | ``` 109 | 110 | ## Distance Functions 111 | 112 | The linked papers use Euclidean distance, but I have been finding good results with CIE94 Textiles & Graphic Arts functions, and with CIE2000 (although it is much slower). You can find these functions in the [image-q](https://github.com/ibezkrovnyi/image-quantization) module, I've also copied a JS version of them from `image-q` to the `demo/utils` folder. 113 | 114 | ## Sorting the input data 115 | 116 | The paper suggests sorting the pixels by Increasing Average Similarity or Decreasing Average Similarity, but this library does not yet handle this. If you are interested in helping, please open an issue. 117 | 118 | ## Post-Processing 119 | 120 | My implementation here adds an additional feature: further palette reduction by cluster disparity. The goal of this is to produce a palette of count `targetColors` with *disparate* colors, by iteratively trimming away very close colours (deleting the least-weighted color) until you end up with the target count. 121 | 122 | If you pass a number into `atcq.getWeightedPalette(n)` function that is smaller than the `maxColors` option, it will try to reduce the number of colors using the following algorithm: 123 | 124 | ``` 125 | palettes = [ ...initialPalettes ] 126 | while palettes.length > targetColors: 127 | links = getMinimumSpanningTree(palettes) 128 | sort links by decreasing distance 129 | while links.length > 0 and palettes.length > targetColors: 130 | { fromColor, toColor } = links.pop() 131 | if fromColor.weight < toColor.weight: 132 | palettes.delete(fromColor) 133 | else 134 | palettes.delete(toColor) 135 | ``` 136 | 137 | I'm not sure if there is a better and more optimal approach here (feel free to open an issue to discuss), but it is producing nice palettes for my needs. 138 | 139 | ## Comparisons 140 | 141 | Taking the baboon image and comparing it to various algorithms in the `image-q` module. The weighting and sorting is normalized for easier comparisons. 142 | 143 | ![palette](./images/2020.05.01-11.46.21-atcq-lab-ciede2000-s0.75-disconnects-max64-q6.png) 144 | *ATCQ, CIEDE200, α0.75, with disconnects, max 64 colors, target 6 colors* 145 | 146 | ![palette](./images/2020.05.01-11.45.23-atcq-lab-ciede2000-s0.2-disconnects-max64-q6.png) 147 | *ATCQ, CIEDE200, α0.2, with disconnects, max 64 colors, target 6 colors* 148 | 149 | ![palette](./images/2020.05.01-11.45.00-wuquant-ciede2000-q6.png) 150 | *WuQuant, CIEDE200, max 6 colors* 151 | 152 | ![palette](./images/2020.05.01-11.44.52-rgbquant-ciede2000-q6.png) 153 | *RGBQuant, CIEDE200, max 6 colors* 154 | 155 | ![palette](./images/2020.05.01-11.44.48-neuquant-ciede2000-q6.png) 156 | *NeuQuant, CIEDE200, max 6 colors* 157 | 158 | See other outputs in the [./images](./images) direcotry. 159 | 160 | ## License 161 | 162 | MIT, see [LICENSE.md](http://github.com/mattdesl/atcq/blob/master/LICENSE.md) for details. 163 | -------------------------------------------------------------------------------- /atcq.js: -------------------------------------------------------------------------------- 1 | const Types = require('./lib/Types'); 2 | const AntNode = require('./lib/AntNode'); 3 | const ClusterNode = require('./lib/ClusterNode'); 4 | const SupportNode = require('./lib/SupportNode'); 5 | const util = require('./lib/util'); 6 | const { getMinimumSpanningTree, getDisjointedSubsets } = require('./lib/mst'); 7 | 8 | // Core API 9 | module.exports = ATCQ; 10 | 11 | // Simple sync API 12 | module.exports.quantizeSync = quantizeSync; 13 | function quantizeSync (pixels, opt = {}) { 14 | const atcq = ATCQ(opt); 15 | atcq.addData(pixels); 16 | atcq.quantizeSync(); 17 | return atcq.getPalette(); 18 | } 19 | 20 | // Simple async API 21 | module.exports.quantizeAsync = quantizeAsync; 22 | async function quantizeAsync (pixels, opt = {}) { 23 | const atcq = ATCQ(opt); 24 | atcq.addData(pixels); 25 | await atcq.quantizeAsync(); 26 | return atcq.getPalette(); 27 | } 28 | 29 | module.exports.SupportNode = SupportNode; 30 | module.exports.ClusterNode = ClusterNode; 31 | module.exports.AntNode = AntNode; 32 | module.exports.NodeType = Types; 33 | 34 | // Not exposed atm... 35 | // module.exports.getMinimumSpanningTree = getMinimumSpanningTree; 36 | // module.exports.getDisjointedSubsets = getDisjointedSubsets; 37 | 38 | // Utilities 39 | module.exports.traverse = util.traverse; 40 | module.exports.traverseDepthFirst = util.traverseDepthFirst; 41 | module.exports.traverseBreadthFirst = util.traverseBreadthFirst; 42 | module.exports.detachTree = util.detachTree; 43 | module.exports.findCluster = util.findCluster; 44 | 45 | function ATCQ (opt = {}) { 46 | const maxColors = opt.maxColors != null ? opt.maxColors : 32; 47 | const nodeChildLimit = opt.nodeChildLimit != null ? opt.nodeChildLimit : Math.max(2, maxColors); 48 | const distanceFunc = opt.distance || util.distanceSquared; 49 | const disconnects = Boolean(opt.disconnects); 50 | const alpha = opt.alpha != null ? opt.alpha : 0.25; 51 | const minDistance = opt.minDistance != null && isFinite(opt.minDistance) ? opt.minDistance : -Infinity; 52 | const random = opt.random != null ? opt.random : (() => Math.random()); 53 | const maxIterations = opt.maxIterations != null && isFinite(opt.maxIterations) ? opt.maxIterations : 1; 54 | 55 | const processed = opt.processed || (() => {}); 56 | const progress = opt.progress || (() => {}); 57 | const step = opt.step || (() => {}); 58 | 59 | const dimensions = opt.dimensions != null ? opt.dimensions : 3; 60 | const progressInterval = opt.progressInterval != null ? opt.progressInterval : 0.2; 61 | const windowSize = opt.windowSize || null; 62 | 63 | if (maxIterations < 1) throw new Error('maxIterations must be > 0'); 64 | if (nodeChildLimit < 2) throw new Error('Invalid child limit: must be >= 2'); 65 | if (maxColors < 1) throw new Error('Invalid max colors, must be > 0'); 66 | 67 | const rootNode = new SupportNode(); 68 | const ants = []; 69 | 70 | let started = false; 71 | let finished = false; 72 | let lastProgress = 0; 73 | let antIndex = 0; 74 | let iterations = 0; 75 | 76 | return { 77 | get ants () { 78 | return ants; 79 | }, 80 | get finished () { 81 | return finished; 82 | }, 83 | get started () { 84 | return started; 85 | }, 86 | getClusters () { 87 | return [ ...rootNode.children ]; 88 | }, 89 | countAntsMoving, 90 | countProgress, 91 | addData, 92 | addPixels, 93 | addPixels1D, 94 | addPixel, 95 | step: stepOnce, 96 | restart, 97 | clear, 98 | finish, 99 | quantizeSync, 100 | quantizeAsync, 101 | getPalette, 102 | getWeightedPalette, 103 | getDisparateClusters 104 | } 105 | 106 | function countAntsMoving () { 107 | let moving = 0; 108 | ants.forEach(ant => { 109 | if (ant.moving) moving++; 110 | }); 111 | return moving; 112 | } 113 | 114 | function countProgress () { 115 | return _computeProgress(countAntsMoving()); 116 | } 117 | 118 | function quantizeSync () { 119 | while (!finished) { 120 | stepOnce(); 121 | } 122 | } 123 | 124 | function quantizeAsync (opt = {}) { 125 | if (finished) return Promise.resolve(); 126 | const { interval = 1 } = opt; 127 | return new Promise((resolve) => { 128 | let handle = setInterval(() => { 129 | stepOnce(); 130 | if (finished) { 131 | clearInterval(handle); 132 | resolve(); 133 | return; 134 | } 135 | }, interval); 136 | }); 137 | } 138 | 139 | function clear () { 140 | restart(); 141 | ants.length = 0; 142 | } 143 | 144 | function restart () { 145 | antIndex = 0; 146 | lastProgress = 0; 147 | started = false; 148 | util.detachTree(rootNode); 149 | finished = false; 150 | } 151 | 152 | function nextIteration () { 153 | ants.forEach(ant => { 154 | ant.disconnect(); 155 | ant.lift(); 156 | // mark as not-yet-disconnected 157 | ant.hasDisconnected = false; 158 | }); 159 | } 160 | 161 | function addData (obj) { 162 | if (Array.isArray(obj)) { 163 | // got an array 164 | if (obj.length === 0 || Array.isArray(obj[0]) || (typeof obj[0] === 'object' && obj[0])) { 165 | // got nested pixel array 166 | addPixels(obj); 167 | } else if (typeof obj[0] === 'number') { 168 | // got flat array 169 | addPixels1D(obj); 170 | } else { 171 | throw new Error('Expected arrays to be in the form [ r1, g1, b1, ... ] or [ pixel0, pixel1, ... ]'); 172 | } 173 | } else if (obj && obj.byteLength != null) { 174 | // got a Buffer or typed array 175 | addPixels1D(obj); 176 | } else if (obj && obj.data != null) { 177 | // got an image data object 178 | addPixels1D(obj.data); 179 | } else { 180 | throw new Error('Unexpected type for addData()'); 181 | } 182 | } 183 | 184 | function addPixels1D (array, stride = 4, channels = 3) { 185 | if (array.length % stride !== 0) { 186 | throw new Error('Flat 1D array has a stride that does not divide the length evenly'); 187 | } 188 | for (let i = 0; i < array.length / stride; i++) { 189 | const pixel = []; 190 | for (let j = 0; j < channels; j++) { 191 | const d = array[i * stride + j]; 192 | pixel.push(d); 193 | } 194 | addPixel(pixel); 195 | } 196 | } 197 | 198 | function addPixels (pixels) { 199 | if (typeof pixels[0] === 'number') { 200 | throw new Error('It looks like you are providing data as a flat RGBA array, you need to first split them into pixels, or use addPixels1D'); 201 | } 202 | pixels.forEach(data => addPixel(data)); 203 | } 204 | 205 | function addPixel (data) { 206 | // create a new ant 207 | const ant = new AntNode(data); 208 | 209 | // Move them to the root node initially 210 | ant.place(rootNode); 211 | 212 | // append to array 213 | ants.push(ant); 214 | } 215 | 216 | function stepOnce () { 217 | // algorithm is done, skip 218 | if (finished) return; 219 | if (!started) { 220 | started = true; 221 | } 222 | 223 | let antCount = 0; 224 | const curWindowSize = windowSize != null && isFinite(windowSize) ? windowSize : ants.length; 225 | const antsPerStep = Math.min(ants.length, curWindowSize); 226 | 227 | // Go through as many ants as we can using our sliding window 228 | for (let c = 0; c < antsPerStep; c++) { 229 | const nextAnt = ants[(c + antIndex) % ants.length]; 230 | antCount++; 231 | // if the ant is moving, i.e. not in the graph yet 232 | if (nextAnt.moving) { 233 | // we got at least one, not yet done iterating 234 | if (!nextAnt.terrain || nextAnt.terrain === rootNode) { 235 | // Ant is on the support node, create a new cluster if we can 236 | supportCase(nextAnt); 237 | } else { 238 | // Ant is child of a cluster or another ant 239 | if (disconnects) { 240 | // run disconnect algorithm 241 | notSupportCase(nextAnt); 242 | } else { 243 | const otherNode = nextAnt.terrain; 244 | // run simpler non-disconnect algorithm 245 | nextAnt.place(otherNode); 246 | otherNode.add(nextAnt); 247 | } 248 | } 249 | } 250 | // an event each time an ant is processed 251 | processed(nextAnt); 252 | } 253 | antIndex += antCount; 254 | if (antIndex >= ants.length) { 255 | antIndex = antIndex % ants.length; 256 | } 257 | 258 | step(); 259 | 260 | const remaining = countAntsMoving(); 261 | const newProgress = _computeProgress(remaining); 262 | let reportProgress = true; 263 | 264 | if (remaining <= 0) { 265 | iterations++; 266 | if (iterations < maxIterations) { 267 | nextIteration(); 268 | } else { 269 | reportProgress = false; 270 | finish(); 271 | progress(1); 272 | } 273 | } 274 | 275 | if (reportProgress) { 276 | if (Math.abs(newProgress - lastProgress) >= progressInterval) { 277 | lastProgress = newProgress; 278 | progress(newProgress); 279 | } 280 | } 281 | } 282 | 283 | function finish () { 284 | finished = true; 285 | } 286 | 287 | function supportCase (ant) { 288 | let createCluster = false; 289 | if (rootNode.childCount === 0) { 290 | createCluster = true; 291 | } else { 292 | // Some clusters exist, find best candidate 293 | const { cluster, distance } = util.findMostSimilarCluster(rootNode, ant, distanceFunc); 294 | const T = cluster.relativeError; 295 | let allowCreation = distance > minDistance; 296 | if (allowCreation && distance < T && rootNode.childCount < maxColors) { 297 | createCluster = true; 298 | } else { 299 | // Move ant toward child of cluster without linking 300 | ant.place(cluster); 301 | cluster.contribute(ant.color, distance); 302 | } 303 | } 304 | 305 | if (createCluster) { 306 | // No clusters yet in tree 307 | // Create a new cluster 308 | const cluster = new ClusterNode(alpha, dimensions); 309 | // add the cluster to the tree 310 | rootNode.add(cluster); 311 | // place the ant on the cluster 312 | ant.place(cluster); 313 | // add the ant to the cluster 314 | cluster.add(ant); 315 | // contribute ant to cluster 316 | cluster.contribute(ant.color); 317 | } 318 | } 319 | 320 | function notSupportCase (ant) { 321 | const otherNode = ant.terrain; 322 | if (!otherNode) throw new Error('Ant terrain is null'); 323 | 324 | const children = [ ...otherNode.children ]; 325 | if (children.length === 0) { 326 | // the node has no children, i.e. no ants connected 327 | // to it, so we add this ant 328 | ant.place(otherNode); 329 | otherNode.add(ant); 330 | } else if (children.length === 2 && !children[1].hasDisconnected) { 331 | const second = children[1]; 332 | util.detachTree(second); 333 | ant.place(otherNode); 334 | otherNode.add(ant); 335 | } else { 336 | const cluster = util.findCluster(ant); 337 | if (!cluster) { 338 | // Ant is free-roaming, i.e. it was on a tree that got killed 339 | // Move this ant back to support as with all its descendents 340 | util.detachTree(ant); 341 | return; 342 | } 343 | 344 | const a0 = children[Math.floor(random() * children.length)]; 345 | const T = cluster.relativeError; 346 | const dist = distanceFunc(ant.color, a0.color); 347 | 348 | // Note: We diverge from the original ATCQ algorithm here 349 | // to better support images with, for example, all black pixels 350 | // where the distance === T. 351 | if (dist <= T && children.length < nodeChildLimit) { 352 | ant.place(otherNode); 353 | otherNode.add(ant); 354 | } else { 355 | ant.place(a0); 356 | } 357 | } 358 | } 359 | 360 | function getDisparateClusters (targetColors, opt = {}) { 361 | let clusters = [ ...rootNode.children ]; 362 | if (targetColors == null) return clusters; 363 | if (targetColors < 0) throw new Error('Expected targetColors to be > 0'); 364 | 365 | if (clusters.length <= targetColors) { 366 | return clusters; 367 | } 368 | 369 | const { 370 | maxIterations = Infinity, 371 | } = opt; 372 | 373 | const distCluster = (a, b) => distanceFunc(a.color, b.color); 374 | 375 | let iterations = 0; 376 | let results = []; 377 | while (clusters.length > targetColors && iterations++ < maxIterations) { 378 | const links = getMinimumSpanningTree(clusters, distCluster, opt); 379 | links.sort((a, b) => b.distance - a.distance); 380 | 381 | while (links.length > 0 && clusters.length > targetColors) { 382 | // trim away the smallest pair, i.e. most similar color links 383 | const link = links.pop(); 384 | const { from: clusterA, to: clusterB } = link; 385 | const clusterASmaller = clusterA.size < clusterB.size; 386 | const clusterToKill = clusterASmaller ? clusterA : clusterB; 387 | const clusterToSave = clusterASmaller ? clusterB : clusterA; 388 | 389 | const idx = clusters.indexOf(clusterToKill); 390 | if (idx !== -1) { 391 | // Haven't yet killed this one, so merge it into save cluster 392 | clusters.splice(idx, 1); 393 | } 394 | } 395 | } 396 | return clusters; 397 | } 398 | 399 | function getWeightedPalette (n) { 400 | let clusters = [ ...rootNode.children ]; 401 | if (n != null && n > 0) { 402 | clusters = getDisparateClusters(n); 403 | } 404 | const totalSize = clusters.reduce((sum, a) => sum + a.size, 0); 405 | return clusters.map(cluster => { 406 | return { 407 | color: cluster.color, 408 | weight: cluster.size / totalSize 409 | }; 410 | }).sort((a, b) => b.weight - a.weight); 411 | } 412 | 413 | function getPalette (n) { 414 | const clusters = getWeightedPalette(n); 415 | return clusters.map(n => n.color); 416 | } 417 | 418 | function _computeProgress (nLenMoving) { 419 | return (1 - nLenMoving / ants.length) * (1 / maxIterations) + iterations / maxIterations; 420 | } 421 | } 422 | -------------------------------------------------------------------------------- /demo/baboon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/atcq/58a84f016da2e8142fb4efef55e0c668e02a9fb5/demo/baboon.png -------------------------------------------------------------------------------- /demo/extract.js: -------------------------------------------------------------------------------- 1 | const { promisify } = require('util'); 2 | const getPixels = promisify(require('get-pixels')); 3 | const ATCQ = require('../atcq'); 4 | const Color = require('canvas-sketch-util/color') 5 | 6 | const file = process.argv[2]; 7 | if (!file) throw new Error('Must supply filename; e.g. node extract image.png'); 8 | 9 | (async () => { 10 | // load your image data as RGBA array 11 | const { data } = await getPixels(file); 12 | 13 | console.log('Quantizing...'); 14 | 15 | const maxColors = 32; 16 | const targetColors = 5; 17 | const atcq = ATCQ({ 18 | maxColors, 19 | disconnects: false, 20 | maxIterations: 5, 21 | progress (t) { 22 | console.log(`Progress: ${Math.floor(t * 100)}%`); 23 | } 24 | }); 25 | atcq.addData(data); 26 | await atcq.quantizeAsync(); 27 | 28 | const palette = atcq.getWeightedPalette(targetColors) 29 | .map(p => { 30 | // convert to hex 31 | return { 32 | ...p, 33 | color: Color.parse(p.color).hex 34 | }; 35 | }); 36 | 37 | // Convert resulting RGB floats to hex code 38 | console.log('Finished quantizing:'); 39 | console.log(palette); 40 | })(); 41 | -------------------------------------------------------------------------------- /demo/util/cie2000.js: -------------------------------------------------------------------------------- 1 | // All taken from https://github.com/ibezkrovnyi/image-quantization 2 | 3 | module.exports = distance; 4 | 5 | function degrees2radians (n) { 6 | return n * (Math.PI / 180); 7 | } 8 | 9 | /** 10 | * Weight in distance: 0.25 11 | * Max DeltaE: 100 12 | * Max DeltaA: 255 13 | */ 14 | const _kA = (0.25 * 100) / 255; 15 | const _pow25to7 = Math.pow(25, 7); 16 | const _deg360InRad = degrees2radians(360); 17 | const _deg180InRad = degrees2radians(180); 18 | const _deg30InRad = degrees2radians(30); 19 | const _deg6InRad = degrees2radians(6); 20 | const _deg63InRad = degrees2radians(63); 21 | const _deg275InRad = degrees2radians(275); 22 | const _deg25InRad = degrees2radians(25); 23 | 24 | function _calculatehp(b, ap) { 25 | const hp = Math.atan2(b, ap); 26 | if (hp >= 0) return hp; 27 | return hp + _deg360InRad; 28 | } 29 | 30 | function _calculateRT(ahp, aCp) { 31 | const aCp_to_7 = Math.pow(aCp, 7.0); 32 | const R_C = 2.0 * Math.sqrt(aCp_to_7 / (aCp_to_7 + _pow25to7)); // 25^7 33 | const delta_theta = 34 | _deg30InRad * 35 | Math.exp( 36 | -(Math.pow(((ahp - _deg275InRad) / _deg25InRad), 2.0)), 37 | ); 38 | return -Math.sin(2.0 * delta_theta) * R_C; 39 | } 40 | 41 | function _calculateT(ahp) { 42 | return ( 43 | 1.0 - 44 | 0.17 * Math.cos(ahp - _deg30InRad) + 45 | 0.24 * Math.cos(ahp * 2.0) + 46 | 0.32 * Math.cos(ahp * 3.0 + _deg6InRad) - 47 | 0.2 * Math.cos(ahp * 4.0 - _deg63InRad) 48 | ); 49 | } 50 | 51 | function _calculate_ahp( 52 | C1pC2p, 53 | h_bar, 54 | h1p, 55 | h2p 56 | ) { 57 | const hpSum = h1p + h2p; 58 | if (C1pC2p === 0) return hpSum; 59 | if (h_bar <= _deg180InRad) return hpSum / 2.0; 60 | if (hpSum < _deg360InRad) { 61 | return (hpSum + _deg360InRad) / 2.0; 62 | } 63 | return (hpSum - _deg360InRad) / 2.0; 64 | } 65 | 66 | function _calculate_dHp( 67 | C1pC2p, 68 | h_bar, 69 | h2p, 70 | h1p 71 | ) { 72 | let dhp; 73 | if (C1pC2p === 0) { 74 | dhp = 0; 75 | } else if (h_bar <= _deg180InRad) { 76 | dhp = h2p - h1p; 77 | } else if (h2p <= h1p) { 78 | dhp = h2p - h1p + _deg360InRad; 79 | } else { 80 | dhp = h2p - h1p - _deg360InRad; 81 | } 82 | return 2.0 * Math.sqrt(C1pC2p) * Math.sin(dhp / 2.0); 83 | } 84 | 85 | function distance (lab1, lab2) { 86 | // Get L,a,b values for color 1 87 | const L1 = lab1[0]; 88 | const a1 = lab1[1]; 89 | const b1 = lab1[2]; 90 | 91 | // Get L,a,b values for color 2 92 | const L2 = lab2[0]; 93 | const a2 = lab2[1]; 94 | const b2 = lab2[2]; 95 | 96 | // Calculate Cprime1, Cprime2, Cabbar 97 | const C1 = Math.sqrt(a1 * a1 + b1 * b1); 98 | const C2 = Math.sqrt(a2 * a2 + b2 * b2); 99 | const pow_a_C1_C2_to_7 = Math.pow(((C1 + C2) / 2.0), 7.0); 100 | 101 | const G = 102 | 0.5 * 103 | (1.0 - 104 | Math.sqrt(pow_a_C1_C2_to_7 / (pow_a_C1_C2_to_7 + _pow25to7))); // 25^7 105 | const a1p = (1.0 + G) * a1; 106 | const a2p = (1.0 + G) * a2; 107 | 108 | const C1p = Math.sqrt(a1p * a1p + b1 * b1); 109 | const C2p = Math.sqrt(a2p * a2p + b2 * b2); 110 | const C1pC2p = C1p * C2p; 111 | 112 | // Angles in Degree. 113 | const h1p = _calculatehp(b1, a1p); 114 | const h2p = _calculatehp(b2, a2p); 115 | const h_bar = Math.abs(h1p - h2p); 116 | const dLp = L2 - L1; 117 | const dCp = C2p - C1p; 118 | const dHp = _calculate_dHp(C1pC2p, h_bar, h2p, h1p); 119 | const ahp = _calculate_ahp(C1pC2p, h_bar, h1p, h2p); 120 | 121 | const T = _calculateT(ahp); 122 | 123 | const aCp = (C1p + C2p) / 2.0; 124 | const aLp_minus_50_square = Math.pow(((L1 + L2) / 2.0 - 50.0), 2.0); 125 | const S_L = 126 | 1.0 + 127 | (0.015 * aLp_minus_50_square) / Math.sqrt(20.0 + aLp_minus_50_square); 128 | const S_C = 1.0 + 0.045 * aCp; 129 | const S_H = 1.0 + 0.015 * T * aCp; 130 | 131 | const R_T = _calculateRT(ahp, aCp); 132 | 133 | const dLpSL = dLp / S_L; // S_L * kL, where kL is 1.0 134 | const dCpSC = dCp / S_C; // S_C * kC, where kC is 1.0 135 | const dHpSH = dHp / S_H; // S_H * kH, where kH is 1.0 136 | 137 | return Math.pow(dLpSL, 2) + Math.pow(dCpSC, 2) + Math.pow(dHpSH, 2) + R_T * dCpSC * dHpSH; 138 | } -------------------------------------------------------------------------------- /demo/util/cie94.js: -------------------------------------------------------------------------------- 1 | // All taken from https://github.com/ibezkrovnyi/image-quantization 2 | 3 | const Textiles = [ 2.0, 0.048, 0.014, (0.25 * 50) / 255 ]; 4 | const GraphicArts = [ 1.0, 0.045, 0.015, (0.25 * 100) / 255 ]; 5 | 6 | const graphicArtsFunc = createCIE94(GraphicArts); 7 | module.exports = graphicArtsFunc; 8 | module.exports.createCIE94 = createCIE94; 9 | module.exports.textiles = createCIE94(Textiles); 10 | module.exports.graphicArts = graphicArtsFunc; 11 | 12 | function createCIE94 (constants) { 13 | const [ _Kl, _K1, _K2, _kA ] = constants; 14 | const _whitePoint = [99.99999999999973, -0.002467729614430425, -0.013943706067887085]; 15 | 16 | return (lab1, lab2) => { 17 | const dL = lab1[0] - lab2[0]; 18 | const dA = lab1[1] - lab2[1]; 19 | const dB = lab1[2] - lab2[2]; 20 | const c1 = Math.sqrt(lab1[1] * lab1[1] + lab1[2] * lab1[2]); 21 | const c2 = Math.sqrt(lab2[1] * lab2[1] + lab2[2] * lab2[2]); 22 | const dC = c1 - c2; 23 | 24 | let deltaH = dA * dA + dB * dB - dC * dC; 25 | deltaH = deltaH < 0 ? 0 : Math.sqrt(deltaH); 26 | 27 | return Math.sqrt( 28 | Math.pow((dL / _Kl), 2) + 29 | Math.pow((dC / (1.0 + _K1 * c1)), 2) + 30 | Math.pow((deltaH / (1.0 + _K2 * c1)), 2) 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /demo/util/lab.js: -------------------------------------------------------------------------------- 1 | // All taken from https://github.com/ibezkrovnyi/image-quantization 2 | 3 | const refX = 0.95047; // ref_X = 95.047 Observer= 2°, Illuminant= D65 4 | const refY = 1.0; // ref_Y = 100.000 5 | const refZ = 1.08883; // ref_Z = 108.883 6 | 7 | const LAB_MIN = [0,-100,-100]; 8 | const LAB_MAX = [100,100,100]; 9 | 10 | module.exports.lab2rgb = lab2rgb; 11 | module.exports.rgb2lab = rgb2lab; 12 | module.exports.xyz2lab = xyz2lab; 13 | module.exports.lab2xyz = lab2xyz; 14 | module.exports.xyz2rgb = xyz2rgb; 15 | 16 | function rgb2lab(rgb) { 17 | rgb = rgb.map(n => inRange0to255Rounded(n)); 18 | const xyz = rgb2xyz(rgb); 19 | return xyz2lab(xyz); 20 | } 21 | 22 | function lab2rgb(lab) { 23 | lab = lab.map((n, i) => { 24 | return Math.max(LAB_MIN[i], Math.min(LAB_MAX[i], n)); 25 | }); 26 | const xyz = lab2xyz(lab); 27 | return xyz2rgb(xyz); 28 | } 29 | 30 | function pivot_xyz2lab(n) { 31 | return n > 0.008856 ? Math.pow(n, (1 / 3)) : 7.787 * n + 16 / 116; 32 | } 33 | 34 | function xyz2lab([ x, y, z ]) { 35 | x = pivot_xyz2lab(x / refX); 36 | y = pivot_xyz2lab(y / refY); 37 | z = pivot_xyz2lab(z / refZ); 38 | 39 | if (116 * y - 16 < 0) throw new Error('Invalid input for XYZ'); 40 | return [ 41 | Math.max(0, 116 * y - 16), 42 | 500 * (x - y), 43 | 200 * (y - z), 44 | ]; 45 | } 46 | 47 | function rgb2xyz([ r, g, b ]) { 48 | // gamma correction, see https://en.wikipedia.org/wiki/SRGB#The_reverse_transformation 49 | r = correctGamma_rgb2xyz(r / 255); 50 | g = correctGamma_rgb2xyz(g / 255); 51 | b = correctGamma_rgb2xyz(b / 255); 52 | 53 | // Observer. = 2°, Illuminant = D65 54 | return [ 55 | r * 0.4124 + g * 0.3576 + b * 0.1805, 56 | r * 0.2126 + g * 0.7152 + b * 0.0722, 57 | r * 0.0193 + g * 0.1192 + b * 0.9505 58 | ]; 59 | } 60 | 61 | function lab2xyz([ L, a, b ]) { 62 | const y = (L + 16) / 116; 63 | const x = a / 500 + y; 64 | const z = y - b / 200; 65 | 66 | return [ 67 | refX * pivot_lab2xyz(x), 68 | refY * pivot_lab2xyz(y), 69 | refZ * pivot_lab2xyz(z) 70 | ]; 71 | } 72 | function pivot_lab2xyz(n) { 73 | return n > 0.206893034 ? Math.pow(n, 3) : (n - 16 / 116) / 7.787; 74 | } 75 | 76 | function correctGamma_rgb2xyz(n) { 77 | return n > 0.04045 ? Math.pow(((n + 0.055) / 1.055), 2.4) : n / 12.92; 78 | } 79 | 80 | // // gamma correction, see https://en.wikipedia.org/wiki/SRGB#The_reverse_transformation 81 | function correctGamma_xyz2rgb(n) { 82 | return n > 0.0031308 ? 1.055 * Math.pow(n, (1 / 2.4)) - 0.055 : 12.92 * n; 83 | } 84 | 85 | function xyz2rgb([x, y, z]) { 86 | // Observer. = 2°, Illuminant = D65 87 | const r = correctGamma_xyz2rgb(x * 3.2406 + y * -1.5372 + z * -0.4986); 88 | const g = correctGamma_xyz2rgb(x * -0.9689 + y * 1.8758 + z * 0.0415); 89 | const b = correctGamma_xyz2rgb(x * 0.0557 + y * -0.204 + z * 1.057); 90 | 91 | return [ 92 | inRange0to255Rounded(r * 255), 93 | inRange0to255Rounded(g * 255), 94 | inRange0to255Rounded(b * 255) 95 | ]; 96 | } 97 | 98 | function inRange0to255Rounded(n) { 99 | n = Math.round(n); 100 | if (n > 255) n = 255; 101 | else if (n < 0) n = 0; 102 | return n; 103 | } 104 | -------------------------------------------------------------------------------- /demo/visual.js: -------------------------------------------------------------------------------- 1 | const canvasSketch = require('canvas-sketch'); 2 | const load = require('load-asset'); 3 | const getPixels = require('get-image-pixels'); 4 | const Color = require('canvas-sketch-util/color'); 5 | const ATCQ = require('../'); 6 | const { getDisjointedSubsets } = require('../lib/mst'); 7 | const colorSpace = require('color-space') 8 | const { lab2rgb, rgb2lab } = require('./util/lab'); 9 | const ImageQ = require('image-q'); 10 | const cie94 = require('./util/cie94'); 11 | const cie2000 = require('./util/cie2000'); 12 | const Palette = ImageQ.utils.Palette; 13 | const Point = ImageQ.utils.Point; 14 | 15 | const settings = { 16 | dimensions: [ 256, 100 ] 17 | }; 18 | 19 | const sketch = async ({ render, update, height }) => { 20 | const maxColors = 16; 21 | const targetColors = 16; 22 | const useLab = true; 23 | const sortResult = false; 24 | const adaptive = true; 25 | const atcqDistFuncs = { 26 | euclidean: undefined, 27 | 'cie94-textiles': cie94.textiles, 28 | 'ciede2000': cie2000, 29 | 'HyAB': (c0, c1) => { 30 | const [ L0, a0, b0 ] = c0; 31 | const [ L1, a1, b1 ] = c1; 32 | const dL = Math.abs(L0 - L1); 33 | const da = a0 - a1; 34 | const db = b0 - b1; 35 | return dL + Math.sqrt(da * da + db * db); 36 | } 37 | }; 38 | 39 | const colorDistanceFormula = 'ciede2000'; 40 | const distanceFunc = useLab ? atcqDistFuncs[colorDistanceFormula] : undefined; 41 | const paletteOnly = false; 42 | const paletteSize = height; 43 | const mainPalette = targetColors; 44 | const mode = 'atcq'; 45 | // used by image-q 46 | const paletteQuantization = 'wuquant'; 47 | 48 | const paletteTypes = paletteOnly ? [ mainPalette ] : [ 49 | // maxColors, 50 | targetColors 51 | ]; 52 | 53 | const alpha = 0.85; 54 | const disconnects = false; 55 | let atcq = ATCQ({ 56 | maxColors, 57 | disconnects, 58 | maxIterations: 7, 59 | distance: distanceFunc, 60 | // windowSize: 1024 * 10, 61 | // nodeChildLimit: 2, 62 | progress (p) { console.log(Math.floor(p * 100)); }, 63 | step() { render(); }, 64 | alpha 65 | }); 66 | 67 | let image, palette; 68 | let outImage; 69 | 70 | async function quantize (src) { 71 | image = await load(src); 72 | if (!paletteOnly) { 73 | update({ 74 | dimensions: [ 75 | image.width, 76 | image.height + paletteTypes.length * paletteSize 77 | ] 78 | }); 79 | } 80 | 81 | atcq.clear(); 82 | 83 | let rgba = getPixels(image); 84 | 85 | if (mode === 'atcq') { 86 | let inputData = useLab ? convertRGBAToLab(rgba) : rgba; 87 | atcq.addData(inputData); 88 | update({ 89 | suffix: [ 90 | 'atcq', 91 | useLab ? 'lab' : 'rgb', 92 | useLab ? colorDistanceFormula : 'euclidean', 93 | `a${alpha}`, 94 | disconnects ? 'disconnects' : 'no-disconnects', 95 | `max${maxColors}`, 96 | `q${mainPalette}` 97 | ].join('-') 98 | }) 99 | render(); 100 | console.log('Quantizing...'); 101 | await atcq.quantizeAsync(); 102 | 103 | const imagePointContainer = ImageQ.utils.PointContainer.fromUint8Array(rgba, image.width, image.height); 104 | const palette = new Palette(); 105 | atcq.getWeightedPalette(targetColors).forEach(p => { 106 | const [r, g, b] = lab2rgb(p.color); 107 | const a = 255; 108 | const color = Point.createByRGBA(r | 0, g | 0, b | 0, a | 0); 109 | palette.add(color); 110 | }) 111 | palette.sort(); 112 | 113 | const distanceCalculator = new ImageQ.distance.Euclidean(); 114 | distanceCalculator.calculateRaw = ( 115 | r1, 116 | g1, 117 | b1, 118 | a1, 119 | r2, 120 | g2, 121 | b2, 122 | a2, 123 | ) => { 124 | const rgb1 = [ r1, g1, b1 ]; 125 | const rgb2 = [ r2, g2, b2 ]; 126 | return distanceFunc( 127 | rgb2lab(rgb1), 128 | rgb2lab(rgb2) 129 | ); 130 | } 131 | const imageQuantizer = new ImageQ.image.NearestColor(distanceCalculator); 132 | const outContainer = imageQuantizer.quantizeSync(imagePointContainer, palette); 133 | outImage = document.createElement('canvas'); 134 | const outCtx = outImage.getContext('2d'); 135 | outImage.width = outContainer.getWidth(); 136 | outImage.height = outContainer.getHeight(); 137 | const imgData = outCtx.createImageData(outContainer.getWidth(), outContainer.getHeight()); 138 | const points = outContainer.getPointArray(); 139 | for (let i = 0, c = 0; i < imgData.width * imgData.height; i++) { 140 | const p = points[c++]; 141 | imgData.data[i * 4 + 0] = p.r; 142 | imgData.data[i * 4 + 1] = p.g; 143 | imgData.data[i * 4 + 2] = p.b; 144 | imgData.data[i * 4 + 3] = p.a; 145 | } 146 | outCtx.putImageData(imgData, 0, 0); 147 | console.log('Done', outContainer); 148 | render(); 149 | } else { 150 | const pc = ImageQ.utils.PointContainer.fromUint8Array(rgba, image.width, image.height); 151 | console.log('Quantizing...'); 152 | const p = await ImageQ.buildPalette([pc], { 153 | // colorDistanceFormula, 154 | paletteQuantization, 155 | colors: mainPalette 156 | }); 157 | palette = p.getPointContainer().getPointArray().map(p => ([ p.r, p.g, p.b ])); 158 | console.log('Done'); 159 | update({ 160 | suffix: [ 161 | paletteQuantization, 162 | colorDistanceFormula, 163 | `q${mainPalette}` 164 | ].join('-') 165 | }) 166 | render(); 167 | } 168 | } 169 | 170 | const f = 'baboon.png'; 171 | quantize(`demo/${f}`); 172 | 173 | return (props) => { 174 | const { width, height, context } = props; 175 | 176 | context.fillStyle = 'white'; 177 | context.fillRect(0, 0, width, height); 178 | 179 | if (image && !paletteOnly) context.drawImage(image, 0, 0, image.width, image.height); 180 | if (outImage && !paletteOnly) context.drawImage(outImage, 0, 0, image.width, image.height); 181 | 182 | if (mode === 'atcq') { 183 | paletteTypes.map(t => atcq.getWeightedPalette(t)).forEach((c, i) => { 184 | if (c && c.length > 0) { 185 | drawPalette(c, { 186 | ...props, 187 | adaptive, 188 | y: height - paletteTypes.length * paletteSize + i * paletteSize, 189 | height: paletteSize 190 | }); 191 | } 192 | }); 193 | } else { 194 | if (palette) { 195 | const c = palette.map(p => { 196 | return { weight: 1 / palette.length, color: p }; 197 | }); 198 | drawPalette(c, { 199 | ...props, 200 | adaptive, 201 | y: height - paletteSize, 202 | height: paletteSize 203 | }); 204 | } 205 | } 206 | }; 207 | 208 | // function getDisjointedClusters () { 209 | // const dist = (a, b) => distanceFunc(a.color, b.color); 210 | // const result = getDisjointedSubsets(atcq.getClusters(), dist, targetColors) 211 | // const { subsets } = result; 212 | // return subsets.map(group => { 213 | // if (group.length <= 0) return []; 214 | // if (group.length === 1) return group[0]; 215 | // group.sort((a, b) => b.size - a.size); 216 | // const sum = { 217 | // size: 0, 218 | // color: [ 0, 0, 0 ] 219 | // }; 220 | // const count = group.length; 221 | // group.reduce((sum, g) => { 222 | // sum.size += g.size; 223 | // for (let i = 0; i < sum.color.length; i++) { 224 | // sum.color[i] += g.color[i]; 225 | // } 226 | // return sum; 227 | // }, sum); 228 | // sum.color = sum.color.map(n => n / count); 229 | // sum.size /= count; 230 | // return sum; 231 | // }) 232 | // } 233 | 234 | function drawPalette (palette, props) { 235 | const { width, height, context, adaptive = true } = props; 236 | 237 | if (palette.length <= 0) return; 238 | palette = palette.slice(); 239 | if (useLab && mode === 'atcq') { 240 | palette = palette.map(p => { 241 | return { 242 | ...p, 243 | color: lab2rgb(p.color) 244 | }; 245 | }); 246 | } 247 | if (sortResult) { 248 | palette.sort((ca, cb) => { 249 | const a = ca.color; 250 | const b = cb.color; 251 | const len0 = a[0] * a[0] + a[1] * a[1] + a[2] * a[2]; 252 | const len1 = b[0] * b[0] + b[1] * b[1] + b[2] * b[2]; 253 | return len1 - len0; 254 | }); 255 | } 256 | 257 | let { x = 0, y = 0 } = props; 258 | 259 | palette.forEach((cluster, i, list) => { 260 | const rgb = cluster.color; 261 | let w = adaptive 262 | ? Math.round(cluster.weight * width) 263 | : Math.round(1 / palette.length * width); 264 | if (i === list.length - 1) { 265 | w = width - x; 266 | } 267 | let h = height; 268 | context.fillStyle = Color.parse({ rgb }).hex; 269 | context.fillRect( 270 | x, 271 | y, 272 | w, 273 | h 274 | ); 275 | x += w; 276 | }); 277 | } 278 | }; 279 | 280 | canvasSketch(sketch, settings); 281 | 282 | function convertRGBAToLab (array) { 283 | const pixels = []; 284 | const channels = 3; 285 | const stride = 4; 286 | for (let i = 0; i < array.length / stride; i++) { 287 | const pixel = []; 288 | for (let j = 0; j < channels; j++) { 289 | const d = array[i * stride + j]; 290 | pixel.push(d); 291 | } 292 | pixels.push(rgb2lab(pixel)); 293 | } 294 | return pixels; 295 | } 296 | -------------------------------------------------------------------------------- /images/2020.05.01-11.43.00-atcq-lab-ciede2000-s0.2-disconnects-q16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/atcq/58a84f016da2e8142fb4efef55e0c668e02a9fb5/images/2020.05.01-11.43.00-atcq-lab-ciede2000-s0.2-disconnects-q16.png -------------------------------------------------------------------------------- /images/2020.05.01-11.43.25-atcq-lab-ciede2000-s0.2-disconnects-q16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/atcq/58a84f016da2e8142fb4efef55e0c668e02a9fb5/images/2020.05.01-11.43.25-atcq-lab-ciede2000-s0.2-disconnects-q16.png -------------------------------------------------------------------------------- /images/2020.05.01-11.43.40-wuquant-ciede2000-q16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/atcq/58a84f016da2e8142fb4efef55e0c668e02a9fb5/images/2020.05.01-11.43.40-wuquant-ciede2000-q16.png -------------------------------------------------------------------------------- /images/2020.05.01-11.43.45-rgbquant-ciede2000-q16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/atcq/58a84f016da2e8142fb4efef55e0c668e02a9fb5/images/2020.05.01-11.43.45-rgbquant-ciede2000-q16.png -------------------------------------------------------------------------------- /images/2020.05.01-11.43.50-neuquant-ciede2000-q16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/atcq/58a84f016da2e8142fb4efef55e0c668e02a9fb5/images/2020.05.01-11.43.50-neuquant-ciede2000-q16.png -------------------------------------------------------------------------------- /images/2020.05.01-11.44.48-neuquant-ciede2000-q6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/atcq/58a84f016da2e8142fb4efef55e0c668e02a9fb5/images/2020.05.01-11.44.48-neuquant-ciede2000-q6.png -------------------------------------------------------------------------------- /images/2020.05.01-11.44.52-rgbquant-ciede2000-q6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/atcq/58a84f016da2e8142fb4efef55e0c668e02a9fb5/images/2020.05.01-11.44.52-rgbquant-ciede2000-q6.png -------------------------------------------------------------------------------- /images/2020.05.01-11.45.00-wuquant-ciede2000-q6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/atcq/58a84f016da2e8142fb4efef55e0c668e02a9fb5/images/2020.05.01-11.45.00-wuquant-ciede2000-q6.png -------------------------------------------------------------------------------- /images/2020.05.01-11.45.23-atcq-lab-ciede2000-s0.2-disconnects-max64-q6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/atcq/58a84f016da2e8142fb4efef55e0c668e02a9fb5/images/2020.05.01-11.45.23-atcq-lab-ciede2000-s0.2-disconnects-max64-q6.png -------------------------------------------------------------------------------- /images/2020.05.01-11.46.21-atcq-lab-ciede2000-s0.75-disconnects-max64-q6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/atcq/58a84f016da2e8142fb4efef55e0c668e02a9fb5/images/2020.05.01-11.46.21-atcq-lab-ciede2000-s0.75-disconnects-max64-q6.png -------------------------------------------------------------------------------- /images/2020.05.01-11.46.45-atcq-lab-ciede2000-s0.75-disconnects-max64-q6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/atcq/58a84f016da2e8142fb4efef55e0c668e02a9fb5/images/2020.05.01-11.46.45-atcq-lab-ciede2000-s0.75-disconnects-max64-q6.png -------------------------------------------------------------------------------- /images/2020.05.01-11.47.07-atcq-lab-ciede2000-s0.2-disconnects-max64-q6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/atcq/58a84f016da2e8142fb4efef55e0c668e02a9fb5/images/2020.05.01-11.47.07-atcq-lab-ciede2000-s0.2-disconnects-max64-q6.png -------------------------------------------------------------------------------- /images/2020.05.01-12.02.20-atcq-lab-ciede2000-a0.75-disconnects-max16-q16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/atcq/58a84f016da2e8142fb4efef55e0c668e02a9fb5/images/2020.05.01-12.02.20-atcq-lab-ciede2000-a0.75-disconnects-max16-q16.png -------------------------------------------------------------------------------- /images/2020.05.01-12.02.49-atcq-lab-ciede2000-a0.75-disconnects-max16-q6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/atcq/58a84f016da2e8142fb4efef55e0c668e02a9fb5/images/2020.05.01-12.02.49-atcq-lab-ciede2000-a0.75-disconnects-max16-q6.png -------------------------------------------------------------------------------- /lib/AntNode.js: -------------------------------------------------------------------------------- 1 | const Node = require('./Node'); 2 | const Types = require('./Types'); 3 | 4 | module.exports = class AntNode extends Node { 5 | constructor (pixel) { 6 | super(); 7 | 8 | this.data = undefined; 9 | this.color = undefined; 10 | if (Array.isArray(pixel)) { 11 | this.color = pixel; 12 | } else { 13 | this.data = pixel; 14 | if (typeof pixel === 'object' && pixel) { 15 | this.color = pixel.color; 16 | } else { 17 | throw new Error('A pixel was not truthy, or did not contain { color } information'); 18 | } 19 | } 20 | 21 | // The node this ant is currently sitting on 22 | // This can be any type of node 23 | this.terrain = null; 24 | } 25 | 26 | get type () { 27 | return Types.Ant; 28 | } 29 | 30 | connect (other) { 31 | this.place(other); 32 | other.add(this); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/ClusterNode.js: -------------------------------------------------------------------------------- 1 | const Node = require('./Node'); 2 | const Types = require('./Types'); 3 | 4 | module.exports = class ClusterNode extends Node { // Children of support 5 | constructor (alpha, channels = 3) { 6 | super(); 7 | this.alpha = alpha; 8 | // number of ants on this cluster 9 | this.size = 0; 10 | this.channels = channels; 11 | // RGB sum of all ants connected to this cluster 12 | this.colorSum = new Array(channels).fill(0); 13 | // current RGB color average 14 | this.color = this.colorSum.slice(); 15 | // the sum of the similarities between each ant in this subtree 16 | // and the color of this subtree when the ant was included in it 17 | this.error = 0; 18 | } 19 | 20 | get relativeError () { 21 | return (this.error / this.size) * this.alpha; 22 | } 23 | 24 | get type () { 25 | return Types.Cluster; 26 | } 27 | 28 | contribute (color, distance = 0) { 29 | this.size++; 30 | this.error += distance; 31 | for (let i = 0; i < this.colorSum.length; i++) { 32 | this.colorSum[i] += color[i]; 33 | this.color[i] = this.colorSum[i] / this.size; 34 | } 35 | } 36 | 37 | consumeColor (otherCluster) { 38 | this.size++; 39 | for (let i = 0; i < this.colorSum.length; i++) { 40 | this.colorSum[i] += otherCluster.color[i]; 41 | this.color[i] = this.colorSum[i] / this.size; 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/Node.js: -------------------------------------------------------------------------------- 1 | module.exports = class Node { 2 | constructor () { 3 | this.children = new Set(); 4 | this.parent = null; 5 | this.terrain = null; 6 | 7 | // Whether this has previously disconnected from the tree 8 | this.hasDisconnected = false; 9 | } 10 | 11 | get moving () { 12 | // ant is moving if no parent exists (i.e. not in tree) 13 | return !this.parent; 14 | } 15 | 16 | place (node) { 17 | if (this.parent) throw new Error('You can only place a node when it has no paernt'); 18 | this.terrain = node; 19 | } 20 | 21 | lift () { 22 | if (this.parent) throw new Error('You can only lift a node after it has been disconnected'); 23 | // lif this node from its current terrain 24 | if (this.terrain) { 25 | this.terrain = null; 26 | } 27 | } 28 | 29 | add (child) { 30 | if (child.parent) throw new Error('Child already added to graph'); 31 | this.children.add(child); 32 | child.parent = this; 33 | } 34 | 35 | remove (child) { 36 | if (this.children.has(child)) { 37 | if (child.parent !== this) throw new Error('Expected child parent to match this'); 38 | child.parent = null; 39 | child.hasDisconnected = true; 40 | this.children.delete(child); 41 | } 42 | } 43 | 44 | disconnect () { 45 | // detaches this node from the parent 46 | if (this.parent) { 47 | this.parent.remove(this); 48 | } 49 | } 50 | 51 | get childCount () { 52 | return this.children.size; 53 | } 54 | } -------------------------------------------------------------------------------- /lib/SupportNode.js: -------------------------------------------------------------------------------- 1 | const Node = require('./Node'); 2 | const Types = require('./Types'); 3 | 4 | module.exports = class Support extends Node { 5 | 6 | constructor () { 7 | super(); 8 | } 9 | 10 | get type () { 11 | return Types.Support; 12 | } 13 | } -------------------------------------------------------------------------------- /lib/Types.js: -------------------------------------------------------------------------------- 1 | // simple number enum for perf in hot code paths 2 | module.exports = { 3 | Support: 0, 4 | Cluster: 1, 5 | Ant: 2 6 | }; -------------------------------------------------------------------------------- /lib/mst.js: -------------------------------------------------------------------------------- 1 | const disjointSet = require('disjoint-set'); 2 | 3 | module.exports.getMinimumSpanningTree = getMinimumSpanningTree; 4 | function getMinimumSpanningTree (items, distFunc, opt = {}) { 5 | const { 6 | maxSteps = Infinity 7 | } = opt; 8 | if (items.length <= 1) return []; 9 | let indices = new Map(); 10 | items.forEach((c, i) => { 11 | indices.set(c, i); 12 | }); 13 | let connected = new Set(items.slice(0, 1)); 14 | let remaining = new Set(items.slice(1)); 15 | let connections = new Map(); 16 | let steps = 0; 17 | let results = []; 18 | while (remaining.size != 0 && steps++ < maxSteps) { 19 | const result = findWithDistance(connected, remaining, distFunc); 20 | if (!result || !isFinite(result.distance)) continue; 21 | 22 | const { 23 | from, 24 | to, 25 | distance 26 | } = result; 27 | 28 | let keys = [ indices.get(from), indices.get(to) ]; 29 | const indexList = keys.slice(); 30 | keys.sort(); 31 | const key = keys.join(':'); 32 | if (!connections.has(key)) { 33 | connections.set(key, true); 34 | results.push({ ...result, indices: indexList, key }); 35 | connected.add(to); 36 | remaining.delete(to); 37 | } 38 | } 39 | return results; 40 | } 41 | 42 | function findWithDistance (connected, remaining, distanceFn) { 43 | let minDist = Infinity; 44 | let from, candidate; 45 | for (let a of connected) { 46 | for (let b of remaining) { 47 | let dist = distanceFn(a, b); 48 | if (dist < minDist) { 49 | minDist = dist; 50 | from = a; 51 | candidate = b; 52 | } 53 | } 54 | } 55 | return { from, to: candidate, distance: minDist }; 56 | } 57 | 58 | module.exports.getDisjointedSubsets = getDisjointedSubsets; 59 | function getDisjointedSubsets (items, distFunc, k, opt = {}) { 60 | if (k < 1) throw new Error('k must be 1 or greater'); 61 | if (items.length <= 0) return { connections: [], subsets: [] }; 62 | let connections = getMinimumSpanningTree(items, distFunc, opt); 63 | 64 | connections = connections.slice(); 65 | if (opt.maxDistance != null) { 66 | connections = connections.filter(c => c.distance < opt.maxDistance); 67 | } 68 | connections.sort((a, b) => a.distance - b.distance); 69 | if (k === 1) return { connections, subsets: [ items ] }; 70 | 71 | let clusters = null; 72 | while (connections.length > 2) { 73 | let newConnections = connections.slice(); 74 | newConnections.pop(); 75 | 76 | const set = disjointSet(); 77 | items.forEach(v => set.add(v)); 78 | newConnections.forEach(({ from, to }) => { 79 | set.union(from, to); 80 | }); 81 | const newClusters = set.extract() 82 | set.destroy(); 83 | 84 | const oldClusters = clusters; 85 | if (clusters == null) { 86 | clusters = newClusters; 87 | } else { 88 | if (newClusters.length > k || (oldClusters && newClusters.length < oldClusters.length)) { 89 | // use old cluster 90 | break; 91 | } else { 92 | // use new cluster 93 | clusters = newClusters; 94 | } 95 | } 96 | 97 | connections = newConnections; 98 | clusters = newClusters; 99 | 100 | if (clusters.length >= k) break; 101 | } 102 | return { connections, subsets: clusters }; 103 | } 104 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | const Types = require('./Types'); 2 | 3 | module.exports.traverse = traverseDepthFirst; 4 | module.exports.traverseDepthFirst = traverseDepthFirst; 5 | function traverseDepthFirst (root, cb) { 6 | const stack = [ root ]; 7 | while (stack.length > 0) { 8 | const node = stack.shift(); 9 | const ret = cb(node, root); 10 | if (ret === false) return; 11 | const children = [ ...node.children ]; 12 | if (children.length > 0) { 13 | stack.unshift(...children); 14 | } 15 | } 16 | } 17 | 18 | module.exports.traverseBreadthFirst = traverseBreadthFirst; 19 | function traverseBreadthFirst (root, cb) { 20 | const stack = [ root ]; 21 | while (stack.length > 0) { 22 | const node = stack.shift(); 23 | const ret = cb(node, root); 24 | if (ret === false) return; 25 | const children = [ ...node.children ]; 26 | if (children.length > 0) { 27 | stack.push(...children); 28 | } 29 | } 30 | } 31 | 32 | // Take a node (typically ant) that is on some 33 | // terrain, and may or may not be connected to it 34 | // Then walk up the terrain until we find a cluster 35 | module.exports.findCluster = findCluster; 36 | function findCluster (node) { 37 | let original = node; 38 | while (node) { 39 | if (node.type === Types.Cluster) return node; 40 | if (node.terrain === node) { 41 | // should never happen, avoid infinite loop 42 | throw new Error('findCluster reached a node terrain that references itself'); 43 | } 44 | if (!node.terrain) { 45 | // Node is a support, or is on the support 46 | return null; 47 | } 48 | node = node.terrain; 49 | } 50 | return null; 51 | } 52 | 53 | module.exports.detachTree = detachTree; 54 | function detachTree (node, newTerrain = null) { 55 | const nodes = []; 56 | traverseDepthFirst(node, n => { 57 | nodes.push(n); 58 | }); 59 | nodes.forEach(n => { 60 | n.disconnect(); 61 | n.lift(); 62 | if (n.type === Types.Ant) n.place(newTerrain); 63 | }); 64 | } 65 | 66 | module.exports.distanceSquared = distanceSquared; 67 | function distanceSquared (a, b) { 68 | const [ r1, g1, b1 ] = a; 69 | const [ r2, g2, b2 ] = b; 70 | const dr = r2 - r1; 71 | const dg = g2 - g1; 72 | const db = b2 - b1; 73 | return dr * dr + dg * dg + db * db; 74 | } 75 | 76 | module.exports.distance = distance; 77 | function distance (a, b) { 78 | return Math.sqrt(distanceSquared(a, b)); 79 | } 80 | 81 | module.exports.findMostSimilarCluster = findMostSimilarCluster; 82 | function findMostSimilarCluster (rootNode, node, distanceFunc) { 83 | let minDist = Infinity; 84 | let candidate; 85 | rootNode.children.forEach(cluster => { 86 | if (cluster !== node) { 87 | const dist = distanceFunc(node.color, cluster.color); 88 | if (dist < minDist) { 89 | minDist = dist; 90 | candidate = cluster; 91 | } 92 | } 93 | }); 94 | return { 95 | cluster: candidate, 96 | distance: minDist 97 | }; 98 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "atcq", 3 | "version": "1.0.6", 4 | "description": "An implementation of Ant-Tree Color Quantization", 5 | "main": "./atcq.js", 6 | "license": "MIT", 7 | "author": { 8 | "name": "Matt DesLauriers", 9 | "email": "dave.des@gmail.com", 10 | "url": "https://github.com/mattdesl" 11 | }, 12 | "dependencies": { 13 | "disjoint-set": "^1.1.8" 14 | }, 15 | "devDependencies": { 16 | "canvas-sketch": "^0.7.3", 17 | "canvas-sketch-cli": "^1.11.7", 18 | "canvas-sketch-util": "^1.10.0", 19 | "color-space": "^1.16.0", 20 | "get-image-pixels": "^1.0.1", 21 | "get-pixels": "^3.3.2", 22 | "image-q": "^2.1.2", 23 | "load-asset": "^1.2.0" 24 | }, 25 | "scripts": {}, 26 | "keywords": [ 27 | "color", 28 | "quantization", 29 | "palette", 30 | "image", 31 | "pixels", 32 | "rgba", 33 | "gif", 34 | "quantize" 35 | ], 36 | "repository": { 37 | "type": "git", 38 | "url": "git://github.com/mattdesl/atcq.git" 39 | }, 40 | "homepage": "https://github.com/mattdesl/atcq", 41 | "bugs": { 42 | "url": "https://github.com/mattdesl/atcq/issues" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /test/test-black.js: -------------------------------------------------------------------------------- 1 | const { promisify } = require('util'); 2 | const getPixels = promisify(require('get-pixels')); 3 | const ATCQ = require('../atcq'); 4 | const Color = require('canvas-sketch-util/color') 5 | const path = require('path'); 6 | 7 | (async () => { 8 | // load your image data as RGBA array 9 | const size = 128; 10 | const colors = [ 11 | [ 0, 0, 0 ], 12 | [ 0, 255, 0 ], 13 | [ 0, 0, 255 ], 14 | [ 255, 0, 0 ] 15 | ] 16 | const data = new Array(size * size).fill(0).map((_, i) => { 17 | return colors[i % colors.length]; 18 | }); 19 | console.log('Quantizing...'); 20 | 21 | const maxColors = 4; 22 | const atcq = ATCQ({ 23 | maxColors, 24 | alpha: 0.95, 25 | maxIterations: 7, 26 | progress (t) { 27 | console.log(`Progress: ${Math.floor(t * 100)}%`); 28 | } 29 | }); 30 | atcq.addData(data); 31 | await atcq.quantizeAsync(); 32 | 33 | const palette = atcq.getWeightedPalette() 34 | .map(p => { 35 | // convert to hex 36 | return { 37 | ...p, 38 | color: Color.parse(p.color).hex 39 | }; 40 | }); 41 | 42 | // Convert resulting RGB floats to hex code 43 | console.log('Finished quantizing:'); 44 | console.log(palette); 45 | })(); 46 | -------------------------------------------------------------------------------- /test/test-high-dimension.js: -------------------------------------------------------------------------------- 1 | const { promisify } = require('util'); 2 | const getPixels = promisify(require('get-pixels')); 3 | const ATCQ = require('../atcq'); 4 | const Color = require('canvas-sketch-util/color') 5 | const path = require('path'); 6 | 7 | function distanceSquared (p0, p1) { 8 | const a = p0; 9 | const b = p1; 10 | let sum = 0; 11 | for (let i = 0; i < a.length; i++) { 12 | const dx = a[i] - b[i]; 13 | sum += dx * dx; 14 | } 15 | return sum; 16 | } 17 | 18 | (async () => { 19 | // load your image data as RGBA array 20 | const palettes = [ 21 | { name: 'ant 1', palette: [ [ 255, 0, 0 ], [ 50, 255, 0 ], [ 30, 128, 0 ] ] }, 22 | { name: 'ant 2 A', palette: [ [ 255, 128, 0 ], [ 0, 55, 20 ], [ 0, 50, 100 ] ] }, 23 | { name: 'ant 2 B', palette: [ [ 250, 120, 0 ], [ 0, 50, 10 ], [ 0, 40, 90 ] ] }, 24 | { name: 'ant 3 A', palette: [ [ 50, 15, 100 ], [ 100, 15, 0 ], [ 20, 150, 50 ] ] }, 25 | { name: 'ant 3 B', palette: [ [ 49, 14, 100 ], [ 100, 5, 0 ], [ 25, 140, 50 ] ] }, 26 | { name: 'ant 3 C', palette: [ [ 51, 10, 100 ], [ 100, 4, 0 ], [ 20, 145, 50 ] ] } 27 | ]; 28 | const size = palettes.length; 29 | const colors = palettes.map(p => { 30 | return { 31 | ...p, 32 | color: p.palette.flat(Infinity) 33 | } 34 | }); 35 | console.log(colors[0]) 36 | const data = new Array(size * size).fill(0).map((_, i) => { 37 | return colors[i % colors.length]; 38 | }); 39 | console.log('Quantizing...'); 40 | 41 | console.log('Dimensions', colors[0].color.length) 42 | const maxColors = 3; 43 | const atcq = ATCQ({ 44 | maxColors, 45 | alpha: 0.95, 46 | maxIterations: 7, 47 | dimensions: colors[0].color.length, 48 | distance: distanceSquared, 49 | progress (t) { 50 | console.log(`Progress: ${Math.floor(t * 100)}%`); 51 | } 52 | }); 53 | atcq.addData(data); 54 | await atcq.quantizeAsync(); 55 | 56 | const palette = atcq.getWeightedPalette(); 57 | 58 | // Convert resulting RGB floats to hex code 59 | console.log('Finished quantizing:'); 60 | console.log(palette); 61 | 62 | const clusters = atcq.getClusters(); 63 | clusters.sort((a, b) => b.size - a.size); 64 | clusters.forEach((cluster, i) => { 65 | console.log(`cluster ${i} -------`) 66 | ATCQ.traverse(cluster, node => { 67 | if (node.type === ATCQ.NodeType.Ant) { 68 | console.log(node.data.name) 69 | } 70 | }) 71 | }) 72 | })(); 73 | --------------------------------------------------------------------------------