Code for a in-browser gif editor project
1/**
2 *
3 * More a miscelaneous file for things related to image and color table changes
4 *
5 */
6
7
8type ImageDataOffset = {
9 imgdata: ImageData
10 offsettop: number
11 offsetleft: number
12}
13
14/**
15 * Recompression experiment. Maybe added in the future. Still needs to normlaize transparency in some way
16 * takes a bunch of base image data and returns a more compressable array of image data
17 * images need to have the same dimensions
18 * @param images
19 * @returns
20 */
21function reframe(images: Array<ImageDataOffset>) {
22 const doneImages: Array<ImageDataOffset> = []
23 doneImages.push({
24 imgdata: new ImageData(Uint8ClampedArray.from(images[0].imgdata.data), images[0].imgdata.width, images[0].imgdata.height),
25 offsetleft: images[0].offsetleft,
26 offsettop: images[0].offsettop
27 })
28
29 let left = [-1, -1], top = [-1, -1], right = [-1, -1], down = [-1, -1] // [x,y] coordinates on the image
30
31 const updateRect = function (newpos: number[]) {
32 if (newpos[0] < left[0] || left[0] < 0) {
33 left = newpos
34 }
35 if (newpos[1] < top[1] || top[1] < 0) {
36 top = newpos
37 }
38 if (newpos[0] > right[0] || right[0] < 0) {
39 right = newpos
40 }
41 if (newpos[1] > down[1] || down[1] < 0) {
42 down = newpos
43 }
44 }
45
46 //transfroms an index into a pixel coordinate on a theoretical logical screen
47 // pixel index has to be the actual idnex of the pixel, not of one of its bytes
48 const indexToCoordinate = function (pixelIndex: number, width: number, offsetleft: number, offsettop: number) {
49 const x = (pixelIndex % width) + offsetleft
50 const y = Math.floor(pixelIndex / width) + offsettop
51 return [x, y]
52 }
53
54 // returns actual index, not of the first byte in image data
55 const coordinatToIndex = function (x: number, y: number, width: number, offsetleft: number, offsettop: number) {
56 return (((y - offsettop) * width) + x - offsetleft)
57 }
58
59 const getOvelap = function (i1: ImageDataOffset, i2: ImageDataOffset) {
60 const left = Math.max(i1.offsetleft, i2.offsetleft)
61 const top = Math.max(i1.offsettop, i2.offsettop)
62
63 const right = Math.min(i1.offsetleft + i1.imgdata.width, i2.imgdata.width + i2.offsetleft)
64 const bottom = Math.min(i1.imgdata.height + i1.offsettop, i2.imgdata.height + i2.offsettop)
65
66 if (right < left || bottom < top) {
67 console.log("images are not overlapping at all")
68 return undefined
69 }
70 return [left, top, right, bottom]
71 }
72
73 const isInBounds = function (x: number, y: number, bl: number, bt: number, br: number, bb: number) {
74 return (bl <= x && x <= br) && (bt <= y && y <= bb)
75 }
76
77 //if either of the indecies are out of the data range this also returns false
78 const samePixelAt = function (i1: ImageData, i1Index: number, i2: ImageData, i2Index: number) {
79 return i1Index < i1.data.length && i2Index < i2.data.length && (
80 i1.data[i1Index * 4] === i2.data[i2Index * 4] &&
81 i1.data[i1Index * 4 + 1] === i2.data[i2Index * 4 + 1] &&
82 i1.data[i1Index * 4 + 2] === i2.data[i2Index * 4 + 2] &&
83 i1.data[i1Index * 4 + 3] === i2.data[i2Index * 4 + 3])
84 }
85
86
87 for (let i = 0; i < images.length - 1; i++) {
88 let newImageData = []
89 const boundary = getOvelap(images[i], images[i + 1])
90 const i2width = images[i + 1].imgdata.width;
91 const i2height = images[i + 1].imgdata.height;
92 const i2top = images[i + 1].offsettop;
93 const i2left = images[i + 1].offsetleft;
94 const i1width = images[i].imgdata.width;
95 const i1heigth = images[i].imgdata.height;
96 const i1top = images[i].offsettop;
97 const i1left = images[i].offsetleft;
98
99 left = [-1, -1], top = [-1, -1], right = [-1, -1], down = [-1, -1]
100
101
102 if (!boundary) { // no overlap so we just put the whole thing into the output
103 doneImages.push({
104 imgdata: new ImageData(Uint8ClampedArray.from(images[i + 1].imgdata.data), images[i + 1].imgdata.width, images[i + 1].imgdata.height),
105 offsetleft: images[i + 1].offsetleft,
106 offsettop: images[i + 1].offsettop
107 })
108 continue
109 }
110
111 // getting the rectangle:
112 for (let i2Pixel = 0; i2Pixel < images[i + 1].imgdata.data.length / 4; i2Pixel++) {
113 const [x, y] = indexToCoordinate(i2Pixel, i2width, i2left, i2top)
114 const i1Pixel = coordinatToIndex(x, y, i1width, i1left, i1top)
115
116 if (!samePixelAt(images[i].imgdata, i1Pixel, images[i + 1].imgdata, i2Pixel)) {
117 updateRect([x, y])
118 }
119 }
120
121
122 // image2 lies completly in image1 = > we do not need to change ther found rectangle
123 if (boundary[0] <= i2left && i2left + i2width <= boundary[2] &&
124 boundary[1] <= i2top && i2top + i2height <= boundary[3]) {
125 }
126 else { //if image 2 lies not completly in image 1 we have to retroactviely update the rectangle to include the edges that are outside of image1's bounds.
127 if (i2top < i1top) {
128 top[1] = i2top
129 }
130 if (i2left < i1left) {
131 left[0] = i2left
132 }
133 if (i2left + i2width > i1left + i1width) {
134 right[0] = i2left + i2width;
135 }
136 if (i2top + i2height > i1top + i1heigth) {
137 down[1] = i2top + i2height
138 }
139 }
140
141 //actually copying pixels within the rectangle and doing transparency stuff
142 for (let i2Pixel = 0; i2Pixel < images[i + 1].imgdata.data.length / 4; i2Pixel++) {
143 const [x, y] = indexToCoordinate(i2Pixel, i2width, i2left, i2top)
144
145 if (isInBounds(x, y, left[0], top[1], right[0], down[1])) {
146 const i1Pixel = coordinatToIndex(x, y, i1width, i1left, i1top)
147 if (samePixelAt(images[i].imgdata, i1Pixel, images[i + 1].imgdata, i2Pixel)) {
148 newImageData.push(0)
149 newImageData.push(0)
150 newImageData.push(0)
151 newImageData.push(0)
152 } else {
153 newImageData.push(images[i + 1].imgdata.data[i2Pixel * 4])
154 newImageData.push(images[i + 1].imgdata.data[i2Pixel * 4 + 1])
155 newImageData.push(images[i + 1].imgdata.data[i2Pixel * 4 + 2])
156 newImageData.push(images[i + 1].imgdata.data[i2Pixel * 4 + 3])
157 }
158 } else {
159
160 }
161 }
162
163 const newWidth = (right[0] - left[0]) + 1
164 const newHeight = (down[1] - top[1]) + 1
165
166 if (newImageData.length == 0) {
167 console.error("newImageData.length ==0")
168 continue
169 }
170
171 //console.log(newWidth)
172 //console.log(newHeight)
173
174 //console.log(left[0], top[1], right[0], down[1])
175
176 doneImages.push({
177 imgdata: new ImageData(Uint8ClampedArray.from(newImageData), newWidth, newHeight),
178 offsetleft: left[0],
179 offsettop: top[1]
180 })
181 }
182
183 return doneImages
184}
185
186// currently not used
187function imageToImageData(imageBlok: GifBlockImage): ImageData {
188
189 const indexStream = decompressor.decompressTableBasedImageData(imageBlok.tableBasedImageData)
190 const width = imageBlok.imagedescriptor.data![5] | (imageBlok.imagedescriptor.data![6] << 8);
191 const height = imageBlok.imagedescriptor.data![7] | (imageBlok.imagedescriptor.data![8] << 8);
192
193 const imagepixels = indexStreamToPicture(indexStream, imageBlok.colortable!.data!);
194 return new ImageData(Uint8ClampedArray.from(imagepixels), width, height,)
195}
196
197// currently not used
198function imagereframertest() {
199
200 if (!gifView) { return }
201
202 const baseimages: ImageDataOffset[] = []
203
204 for (let block of gifView.blocks) {
205 if (block.blockType == BlockType.TableBasedImageData) {
206 const bundled = gifView!.bundleImage(block)!
207 baseimages.push({
208 imgdata: imageToImageData(bundled),
209 offsettop: (bundled.imagedescriptor as ImageDescriptor).topPosition(),
210 offsetleft: (bundled.imagedescriptor as ImageDescriptor).leftPosition()
211 })
212 }
213 }
214
215 const reframed = reframe(baseimages)
216
217 const parentDiv = document.createDocumentFragment()
218 parentDiv.appendChild(document.createElement("p"))
219
220 for (let ref of reframed) {
221 const canvas = document.createElement("canvas") as HTMLCanvasElement
222 canvas.classList.add("canvas-element")
223 canvas.width = ref.imgdata.width
224 canvas.height = ref.imgdata.height
225 canvas.getContext("2d")?.putImageData(ref.imgdata, 0, 0)
226
227 parentDiv.appendChild(canvas)
228 }
229
230 document.getElementById("canvas-div")!.replaceChildren()
231 document.getElementById("canvas-div")!.append(parentDiv)
232
233}
234
235/**
236 * functions that change a colortable wrt certain visual needs. Recoloring happens as a side effect on the given colortables
237 */
238namespace ColorTableEffects {
239
240 /**
241 * Applies the given function to each color table. Tables should be changed as a side effect of the effect function.
242 * @param effect the effect function from ColorTableEffects to be used on every color table
243 * @param options parameters to be passed to the effect function
244 * @example //apply a red shift of +10 to all color tables
245 * ColorTableEffects.applyColorTableEffectToAll(colorshift, 10, 0, 0)
246 */
247 export function applyColorTableEffectToAll(effect: Function, ...options: any) {
248 if (!gifView) { return }
249 if (gifView.globalColorTable) {
250 effect(gifView.globalColorTable as ColorTable, ...options)
251 }
252
253 for (let lct of gifView.getBlocksOfType(BlockType.LocalColorTable)) {
254 effect(lct as ColorTable, ...options);
255 }
256 }
257
258 /**
259 * Converts every color in the given color table to a grey color using the average of its rgb values.
260 * @param colorTable Color table to be changed
261 */
262 export function greyscale(colorTable: ColorTable) {
263 for (let i = 0; i < colorTable.data.length / 3; i++) {
264 const value = (colorTable.data[i * 3] + colorTable.data[i * 3 + 1] + colorTable.data[i * 3 + 2]) / 3;
265 colorTable.data[i * 3] = value;
266 colorTable.data[i * 3 + 1] = value;
267 colorTable.data[i * 3 + 2] = value;
268 }
269 }
270
271 export function colorshift(colorTable: ColorTable, redshift = 0, greenshift = 0, blueshift = 0) {
272 for (let i = 0; i < colorTable.data!.length / 3; i++) {
273 colorTable.data![i * 3] = Math.min(255, (colorTable.data![i * 3] + redshift));
274 colorTable.data![i * 3 + 1] = Math.min(255, (colorTable.data![i * 3 + 1] + blueshift));
275 colorTable.data![i * 3 + 2] = Math.min(255, (colorTable.data![i * 3 + 2] + greenshift));
276 }
277 }
278
279 /**
280 * Allows the application of any kind of recoloring on a color to color basis
281 * @param colorTable colortable to be changed
282 * @param fct A function with signature (r,g,b) => [r,g,b] which is then applied to every colortable entry
283 */
284 function recolor(colorTable: ColorTable, fct: Function) {
285 for (let i = 0; i < colorTable.data!.length / 3; i++) {
286 const [r, g, b] = fct(colorTable.data![i * 3], colorTable.data![i * 3 + 1], colorTable.data![i * 3 + 2])
287 colorTable.data![i * 3] = r;
288 colorTable.data![i * 3 + 1] = g;
289 colorTable.data![i * 3 + 2] = b;
290 }
291 }
292
293 /**
294 * Get rid of excess colors via merging the closest neighbours. Either takes the average of both merged colors, or subsumes the "lower" color into the "higher" one
295 * @param imageBundle GifBlockImage holding the relevant data. Will be changed through the function.
296 * @param targetAmount Amount of colors that should remain. If it is greater than colors exist, nothing is done
297 */
298 function colorMerge(imageBundle: GifBlockImage, targetAmount: number, middle = false) {
299
300
301 }
302
303}
304
305/**
306 * Experimental function that produces a gif displaying all 24bit colors.
307 */
308function makeImageFULLCOLORGIF() {
309
310 const header = [
311 0x47, 0x49, 0x46, //GIF
312 0x38, 0x39, 0x61 //89a
313 ];
314
315 const lsd = [
316 0x00, 0x10, //width
317 0x00, 0x10, //height
318 0b01110000, //packed fields
319 0x00, //background color index
320 0x00 // pixel aspect ratio
321 ];
322
323 const gce = [
324 0x21, // extension introducer
325 0xF9, // Graphic Control Label
326 0x4, // Block Size
327 0b00000100,//<Packed Fields>
328 0x05, 0x00,//Delay Time //should be 5/100 seconds
329 0x0,
330 0x0,
331 ];
332
333 const uint16ToLE = (x: number): number[] => {
334 const s = [];
335 s.push(x & 0b11111111);
336 s.push((x >> 8) & 0b11111111);
337 return s;
338 };
339
340
341 const makeimg = (leftPos: number, topPos: number) => {
342 return [
343 0x2C, //Image Separator
344 leftPos & 0b11111111, (leftPos >> 8) & 0b11111111, //Image Left Position
345 topPos & 0b11111111, (topPos >> 8) & 0b11111111, //Image Top Position
346 0x00, 0x01, //Image Width //256
347 0x01, 0x00, //Image Height //1
348 0b10000111 // packed fields
349 ]
350 };
351
352
353 const id = [
354 0x2C, //Image Separator
355 0x00, 0x00, //Image Left Position
356 0x00, 0x00, //Image Top Position
357 0x00, 0x01, //Image Width //256
358 0x01, 0x00, //Image Height //1
359 0b10000111 // packed fields
360 ];
361
362 const trailer = [0x3B];
363
364 let gif: number[] = [];
365 gif.push(...header);
366 gif.push(...lsd);
367
368 const compressed = compressor.compress(Array.from(Array(256).keys()), 8).reverse(); // should result in a compression of the codes 0 to 255
369
370 const tbid = [
371 0x08, // min lzw size
372 0xFF, //size of the first subdata block
373 ];
374
375 tbid.push(...compressed.slice(0, 255));
376 tbid.push(compressed.length - 255); //we are going to be over the 255 bytes alone through having 256 color codes in the data
377 tbid.push(...compressed.slice(255));
378 tbid.push(0); //last subdatablock with 0 length
379
380
381 let colorm: number[] = [];
382 let leftoffest = 0;
383 let topoffset = 0;
384 for (let r = 0; r < 256; r++) {
385 for (let g = 0; g < 256; g++) {
386 for (let b = 0; b < 256; b++) {
387 colorm.push(...[r, g, b]);
388
389 if (colorm.length == 256 * 3) {
390 //add a new image
391 gif.push(...gce);
392 gif.push(...makeimg(leftoffest * 256, topoffset));
393 gif.push(...colorm);
394 gif.push(...tbid);
395 colorm = [];
396 leftoffest++;
397 if (leftoffest == 16) {
398 leftoffest = 0;
399 topoffset++;
400 }
401 }
402 }
403 }
404 }
405
406 gif.push(...trailer);
407
408 downloadBlob(new Blob([Uint8Array.from(gif)], { type: "image/gif" }), "jsfullcolorgif.gif");
409
410}
411
412/**
413 * Type to keep track of colors during posterization
414 */
415type kMeanColorpoint = {
416 id: number, // id in the colortable
417 color: [number, number, number],
418 groupID: number
419}
420
421/**
422 * Kmeans using the triangle inequality and some other stuff to be quicker
423 * @example const posterizer = new colorPosterizer()
424 * posterizer.setCentroidAmount(256) // as many centroids as we want to have colors at the end
425 * posterizer.initPoints(colors) // unique colors in source image as starting points
426 * posterizer.calc(100) //max 100 iterations
427 * const mapping = posterizer.createIDToGroupMapping() // maps source color indices onto cluster ids
428 */
429class colorPosterizer {
430
431 private points: kMeanColorpoint[]
432 private means: [number, number, number][]
433 private groupings: Map<number, kMeanColorpoint[]> //maps a group id to the points in that centroid. Trades extra memory for a good speed boost
434 private meanDistances: Map<number, [number, number][]> // meanID -> [other meanID, distance to other mean]
435 private centroidAmount: number;
436
437 constructor() {
438 this.points = [];
439 this.means = [];
440 this.centroidAmount = 1;
441 this.groupings = new Map();
442 this.meanDistances = new Map();
443 }
444
445 /**
446 * Calculates the distance between two 3D points
447 * @returns Distance between the two given points
448 */
449 private static distance(point1: [number, number, number], point2: [number, number, number]) {
450 return Math.sqrt(
451 Math.pow(point1[0] - point2[0], 2) +
452 Math.pow(point1[1] - point2[1], 2) +
453 Math.pow(point1[2] - point2[2], 2)
454 )
455 }
456
457 /**
458 * Initializes color points so that means calcualtion can start.
459 * @param colortable Color data where 3 consecutive indices give the rgb components for a color.
460 */
461 public initPoints(colortable: number[]) {
462 if (colortable.length % 3 !== 0) {
463 throw new Error("colortable not long enough")
464 }
465 this.points = []
466 for (let i = 0; i < colortable.length / 3; i++) {
467 const point: kMeanColorpoint = {
468 id: i,
469 color: [colortable[i * 3], colortable[i * 3 + 1], colortable[i * 3 + 2]],
470 groupID: 0
471 }
472 this.points.push(point)
473 }
474 }
475
476 /**
477 * Sets the amount of centroids to be used when calculating means
478 */
479 public setCentroidAmount(amount: number) {
480 this.centroidAmount = Math.max(amount, 1)
481 }
482
483 /**
484 * call after running the .calc() method to obtain a mapping of point IDs to their respective cluster.
485 * This can be used then for example to map indices in a indexstream
486 * @returns map of point id -> centroid id
487 */
488 public createIDToGroupMapping() {
489 const mapping: Map<number, number> = new Map()
490 for (let p of this.points) {
491 mapping.set(p.id, p.groupID)
492 }
493
494 return mapping
495 }
496
497 /**
498 * Actual calculation of kmeans. groups the points and moves means until convergence or maxIterations is reached.
499 */
500 public calc(maxIterations = 1) {
501 this.initializeMeans()
502 this.centroidDistances()
503 let iterations = 0;
504 let notDone = true
505 while (notDone) {
506 iterations++
507 notDone = this.assignPoints()
508 this.moveMeans(true)
509 this.centroidDistances()
510 if (iterations >= maxIterations) {
511 break;
512 }
513 }
514 }
515
516 /**
517 * Creates a color table based on the current grouping of points. Grouping order = color index order
518 * @param type Type of the returned color table
519 * @param useMeans Flag if the group mean should be used. If false, the point closest to the mean is used. True by default
520 * @return a new colortable with as many colors as there are groups
521 */
522 public createColors(useMeans = true) {
523 const colors: number[] = []
524
525 if (useMeans) {
526 for (let i = 0; i < this.means.length; i++) {
527 colors.push(Math.round(this.means[i][0]))
528 colors.push(Math.round(this.means[i][1]))
529 colors.push(Math.round(this.means[i][2]))
530 }
531 } else {
532 const closest = this.closestToCenter()
533 for (let i = 0; i < closest.length; i++) {
534 colors.push(closest[i].color[0])
535 colors.push(closest[i].color[1])
536 colors.push(closest[i].color[2])
537 }
538 }
539 return colors
540 }
541
542 /**
543 * returns a list of points such that per grouping the point closest to the groups mean is included
544 */
545 private closestToCenter() {
546 const minPoints: kMeanColorpoint[] = []
547 for (let i = 0; i < this.means.length; i++) {
548 const mean = this.means[i]
549 let minPoint: kMeanColorpoint = { id: 0, groupID: 0, color: [0, 0, 0] };
550 let minDist = 10000; //way too high distance so we def will have a change
551 for (let point of this.points) {
552 if (point.groupID === i) {
553 const distance = colorPosterizer.distance(point.color, mean)
554 if (distance < minDist) {
555 minPoint = point
556 minDist = distance
557 }
558 }
559 }
560 if (typeof minPoint === "undefined") {
561
562 }
563 minPoints.push({ id: minPoint!.id, groupID: minPoint!.groupID, color: [minPoint!.color[0], minPoint!.color[1], minPoint!.color[2]] })
564 }
565 return minPoints
566 }
567
568 /**
569 * Seeds as many mean points as we have clusters.
570 * Either uses r,g,b points, or grey ones
571 * @param grey flag to indivcate if grey points should be used
572 */
573 private initializeMeans(grey = false) {
574 this.means = []
575 this.groupings.clear()
576
577 // instead of using random points in the point cloud. We use pure red green and blue points
578 if (!grey) {
579 const addertoadder = Math.round(256 * 3 / this.centroidAmount)
580 let adder = 0;
581 for (let i = 0; i < this.centroidAmount; i++) {
582 this.groupings.set(i, [])
583 const color: [number, number, number] = [0, 0, 0]
584 if (i % 3 === 0) {
585 adder = Math.min(adder + addertoadder, 255)
586 }
587 switch (i % 3) {
588 case 0:
589 color[0] = adder
590 break;
591 case 1:
592 color[1] = adder
593 break;
594 case 2:
595 color[2] = adder
596 break;
597 default:
598 break;
599 }
600 this.means.push(color)
601 this.groupings.set(i, [])
602 }
603 if (this.centroidAmount === 256) { // edgecase where we would have the same color twice
604 this.means[255] = [0, 0, 0]
605 }
606
607 } else {
608 for (let i = 0; i < this.centroidAmount; i++) {
609 const color: [number, number, number] = [i * (256 / this.centroidAmount), i * (256 / this.centroidAmount), i * (256 / this.centroidAmount)]
610 this.means.push(color)
611 this.groupings.set(i, [])
612 }
613 }
614 }
615
616 /**
617 * Moves each mean to the center of its cluster.
618 * @param usePoints flag to indicate if means should be set to nearest point of center instead of the center itself
619 */
620 private moveMeans(usePoints = false) {
621 for (let i = 0; i < this.means.length; i++) {
622
623 const groupPoints = this.groupings.get(i)!
624 if (groupPoints.length === 0) {
625 continue
626 }
627 const meancolor: [number, number, number] = [0, 0, 0]
628 //With high color pictures could this run into an overflow?
629 for (let p of groupPoints) {
630 meancolor[0] = meancolor[0] + p.color[0]
631 meancolor[1] = meancolor[1] + p.color[1]
632 meancolor[2] = meancolor[2] + p.color[2]
633 }
634 meancolor[0] = meancolor[0] / groupPoints.length
635 meancolor[1] = meancolor[1] / groupPoints.length
636 meancolor[2] = meancolor[2] / groupPoints.length
637 this.means[i] = meancolor
638
639 if (usePoints) {
640 let minPoint = groupPoints[0];
641 let minDist = colorPosterizer.distance(minPoint.color, this.means[i])
642
643 for (let point of groupPoints) {
644 const distance = colorPosterizer.distance(point.color, this.means[i])
645 if (distance < minDist) {
646 minPoint = point
647 minDist = distance
648 }
649 }
650 this.means[i] = minPoint.color
651 }
652
653 this.groupings.set(i, [])! //reset points of the centroid because assign points will fill them in again.
654 }
655 }
656
657 /**
658 * Assigns points to clusters based on the current means.
659 * @returns true if any points have changed their cluster designation, else false.
660 */
661 private assignPoints() {
662 let clusterChange = false;
663 for (let point of this.points) {
664 const meansToCheck = this.meanDistances.get(point.groupID)! // [ID, distance]
665 let minMeanIndex = point.groupID;
666 let minDist = colorPosterizer.distance(point.color, this.means[point.groupID])
667 const prevDistance = minDist;
668
669 for (let i = 1; i < meansToCheck.length; i++) {
670 if (meansToCheck[i][1] >= 4 * prevDistance) { // there can not be a mean closer to our point
671 break;
672 }
673 const distance = colorPosterizer.distance(point.color, this.means[meansToCheck[i][0]])
674 if (distance < minDist) {
675 minMeanIndex = meansToCheck[i][0];
676 minDist = distance
677 }
678 }
679 const prevID = point.groupID
680 point.groupID = minMeanIndex;
681 this.groupings.get(minMeanIndex)!.push(point)
682
683 if (point.groupID !== prevID) {
684 clusterChange = true;
685 }
686 }
687 return clusterChange
688 }
689
690 /**
691 * Calculates distances between centroids and stores them in ascending order. Needed for the triangle inequality speed up.
692 */
693 private centroidDistances() {
694 for (let i = 0; i < this.means.length; i++) {
695 const distances: [number, number][] = []
696 for (let j = 0; j < this.means.length; j++) { //we could cut this in half if we were smarter about it...
697 distances.push([j, colorPosterizer.distance(this.means[i], this.means[j])])
698 }
699 distances.sort(
700 (a, b) => a[1] - b[1]
701 )
702 this.meanDistances.set(i, distances)
703 }
704 }
705
706}