Code for a in-browser gif editor project
at master 706 lines 22 kB view raw
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}