├── .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 | [](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 | 
14 |
15 | With post-processing the data, we can further reduce to 6 disparate colors:
16 |
17 | 
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 | 
144 | *ATCQ, CIEDE200, α0.75, with disconnects, max 64 colors, target 6 colors*
145 |
146 | 
147 | *ATCQ, CIEDE200, α0.2, with disconnects, max 64 colors, target 6 colors*
148 |
149 | 
150 | *WuQuant, CIEDE200, max 6 colors*
151 |
152 | 
153 | *RGBQuant, CIEDE200, max 6 colors*
154 |
155 | 
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 |
--------------------------------------------------------------------------------