├── .gitignore ├── src-cli ├── testinput.png ├── testinputmedium.png ├── tsconfig.json ├── tslint.json ├── settings.json ├── main.js.map └── main.ts ├── src ├── structs │ ├── boundingbox.ts │ ├── point.ts │ └── typedarrays.ts ├── random.ts ├── common.ts ├── tsconfig.json ├── tslint.json ├── settings.ts ├── main.ts ├── lib │ ├── clustering.ts │ ├── colorconversion.ts │ ├── fill.ts │ ├── polylabel.ts │ ├── clipboard.ts │ └── datastructs.ts ├── facetLabelPlacer.ts ├── facetmanagement.ts ├── facetCreator.ts ├── gui.ts ├── colorreductionmanagement.ts ├── facetReducer.ts └── facetBorderSegmenter.ts ├── package.json ├── .vscode └── launch.json ├── styles └── main.css ├── LICENSE ├── dist └── styles │ └── main.css ├── .github └── workflows │ └── main.yml ├── README.md └── scripts └── lib ├── require.js └── saveSvgAsPng.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.js 3 | *.map -------------------------------------------------------------------------------- /src-cli/testinput.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drake7707/paintbynumbersgenerator/HEAD/src-cli/testinput.png -------------------------------------------------------------------------------- /src-cli/testinputmedium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drake7707/paintbynumbersgenerator/HEAD/src-cli/testinputmedium.png -------------------------------------------------------------------------------- /src/structs/boundingbox.ts: -------------------------------------------------------------------------------- 1 | export class BoundingBox { 2 | 3 | public minX: number = Number.MAX_VALUE; 4 | public minY: number = Number.MAX_VALUE; 5 | public maxX: number = Number.MIN_VALUE; 6 | public maxY: number = Number.MIN_VALUE; 7 | 8 | get width(): number { 9 | return this.maxX - this.minX + 1; 10 | } 11 | get height(): number { 12 | return this.maxY - this.minY + 1; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/random.ts: -------------------------------------------------------------------------------- 1 | 2 | export class Random { 3 | 4 | private seed: number; 5 | public constructor(seed?: number) { 6 | if (typeof seed === "undefined") { 7 | this.seed = new Date().getTime(); 8 | } else { 9 | this.seed = seed; 10 | } 11 | } 12 | 13 | public next(): number { 14 | const x = Math.sin(this.seed++) * 10000; 15 | return x - Math.floor(x); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src-cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "strict": true, 5 | "noImplicitReturns": true, 6 | "noImplicitThis": true, 7 | "strictNullChecks": true, 8 | "removeComments": false, 9 | "sourceMap": true, 10 | "target": "es5", 11 | "lib": [ 12 | "dom", 13 | "es2015", 14 | "es2015.promise" 15 | ], 16 | } 17 | } -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | 2 | export type RGB = number[]; 3 | 4 | export interface IMap { 5 | [key: string]: T; 6 | } 7 | 8 | export async function delay(ms: number) { 9 | if (typeof window !== "undefined") { 10 | return new Promise((exec) => ( window).setTimeout(exec, ms)); 11 | } else { 12 | return new Promise((exec) => exec()); 13 | } 14 | } 15 | 16 | export class CancellationToken { 17 | public isCancelled: boolean = false; 18 | } 19 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outFile": "../scripts/main.js", 4 | "noImplicitAny": true, 5 | "strict": true, 6 | "noImplicitReturns": true, 7 | "noImplicitThis": true, 8 | "strictNullChecks": true, 9 | "removeComments": false, 10 | "sourceMap": false, 11 | "module": "amd", 12 | "target": "es6", 13 | "lib": [ 14 | "dom", 15 | "es2015", 16 | "es2015.promise" 17 | ], 18 | "moduleResolution": "node", 19 | } 20 | } -------------------------------------------------------------------------------- /src/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rulesDirectory": [], 4 | "rules": { 5 | "max-classes-per-file":false, 6 | "max-line-length":false, 7 | "no-console":false, 8 | "no-bitwise":false, 9 | "member-ordering":false, 10 | "promise-function-async":true, 11 | "await-promise":true, 12 | "only-arrow-functions":false, 13 | "variable-name": [ false ], 14 | "no-angle-bracket-type-assertion": false, 15 | "space-before-function-paren": false 16 | }, 17 | "jsRules": { 18 | 19 | } 20 | } -------------------------------------------------------------------------------- /src-cli/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rulesDirectory": [], 4 | "rules": { 5 | "max-classes-per-file":false, 6 | "max-line-length":false, 7 | "no-console":false, 8 | "no-bitwise":false, 9 | "member-ordering":false, 10 | "promise-function-async":true, 11 | "await-promise":true, 12 | "only-arrow-functions":false, 13 | "variable-name": [ false ], 14 | "no-angle-bracket-type-assertion": false, 15 | "space-before-function-paren": false 16 | }, 17 | "jsRules": { 18 | 19 | } 20 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "paint-by-numbers-generator", 3 | "version": "1.0.0", 4 | "description": "Paint by numbers generator", 5 | "bin": "./src-cli/main.js", 6 | "scripts": { 7 | "lite": "lite-server --port 10001", 8 | "start": "npm run lite" 9 | }, 10 | "author": "drake7707", 11 | "devDependencies": { 12 | "@types/node": "^12.7.1", 13 | "lite-server": "^1.3.1" 14 | }, 15 | "dependencies": { 16 | "@types/jquery": "^3.3.31", 17 | "@types/materialize-css": "^1.0.6", 18 | "@types/minimist": "^1.2.0", 19 | "canvas": "2.5.0", 20 | "minimist": "^1.2.3", 21 | "svg2img": "^0.6.1" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Run cli", 11 | "program": "${workspaceFolder}/src-cli/main.js", 12 | "args": [ "-i", "testinputmedium.png", "-o", "testinput.svg"], 13 | "cwd":"${workspaceFolder}/src-cli", 14 | "outFiles": [ 15 | "${workspaceFolder}/**/*.js" 16 | ] 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /src/structs/point.ts: -------------------------------------------------------------------------------- 1 | 2 | export class Point { 3 | constructor(public x: number, public y: number) { } 4 | 5 | public distanceTo(pt: Point): number { 6 | 7 | // don't do euclidean because then neighbours should be diagonally as well 8 | // because sqrt(2) < 2 9 | // return Math.sqrt((pt.x - this.x) * (pt.x - this.x) + (pt.y - this.y) * (pt.y - this.y)); 10 | return Math.abs(pt.x - this.x) + Math.abs(pt.y - this.y); 11 | } 12 | 13 | public distanceToCoord(x: number, y: number): number { 14 | // don't do euclidean because then neighbours should be diagonally as well 15 | // because sqrt(2) < 2 16 | // return Math.sqrt((pt.x - this.x) * (pt.x - this.x) + (pt.y - this.y) * (pt.y - this.y)); 17 | return Math.abs(x - this.x) + Math.abs(y - this.y); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /styles/main.css: -------------------------------------------------------------------------------- 1 | .status { 2 | background-color: white; 3 | padding: 10px; 4 | overflow: hidden; 5 | } 6 | 7 | .status.active { 8 | animation-name: color; 9 | animation-duration: 4s; 10 | animation-iteration-count: infinite; 11 | } 12 | 13 | .status.complete { 14 | background-color: #CCFFC6; 15 | } 16 | 17 | @keyframes color { 18 | 0% { 19 | background-color: #FFF; 20 | } 21 | 50% { 22 | background-color: #CCFFC6; 23 | } 24 | 100% { 25 | background-color: #FFF; 26 | } 27 | } 28 | 29 | .palette .color { 30 | float: left; 31 | width: 40px; 32 | height: 40px; 33 | border: 1px solid #AAA; 34 | border-radius: 5px; 35 | text-align: center; 36 | padding: 5px; 37 | font-weight: 600; 38 | margin: 5px; 39 | text-shadow: #FFF 0px 0px 5px; 40 | } 41 | 42 | .collection-item > .row { 43 | margin-bottom:0px; 44 | } 45 | 46 | #input-pane { 47 | overflow: auto; 48 | max-width: 100%; 49 | max-height: 500px; 50 | } 51 | 52 | #svgContainer { 53 | overflow: auto; 54 | max-width: 100%; 55 | max-height: 500px; 56 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /dist/styles/main.css: -------------------------------------------------------------------------------- 1 | .status { 2 | background-color: white; 3 | padding: 10px; 4 | overflow: hidden; 5 | } 6 | 7 | .status.active { 8 | animation-name: color; 9 | animation-duration: 4s; 10 | animation-iteration-count: infinite; 11 | } 12 | 13 | .status.complete { 14 | background-color: #CCFFC6; 15 | } 16 | 17 | @keyframes color { 18 | 0% { 19 | background-color: #FFF; 20 | } 21 | 50% { 22 | background-color: #CCFFC6; 23 | } 24 | 100% { 25 | background-color: #FFF; 26 | } 27 | } 28 | 29 | .palette .color { 30 | float: left; 31 | width: 40px; 32 | height: 40px; 33 | border: 1px solid #AAA; 34 | border-radius: 5px; 35 | text-align: center; 36 | padding: 5px; 37 | font-weight: 600; 38 | margin: 5px; 39 | text-shadow: #FFF 0px 0px 5px; 40 | } 41 | 42 | .collection-item > .row { 43 | margin-bottom:0px; 44 | } 45 | 46 | #input-pane { 47 | overflow: auto; 48 | max-width: 100%; 49 | max-height: 500px; 50 | } 51 | 52 | #svgContainer { 53 | overflow: auto; 54 | max-width: 100%; 55 | max-height: 500px; 56 | } -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { RGB } from "./common"; 2 | 3 | export enum ClusteringColorSpace { 4 | RGB = 0, 5 | HSL = 1, 6 | LAB = 2, 7 | } 8 | 9 | export class Settings { 10 | public kMeansNrOfClusters: number = 16; 11 | public kMeansMinDeltaDifference: number = 1; 12 | public kMeansClusteringColorSpace: ClusteringColorSpace = ClusteringColorSpace.RGB; 13 | 14 | public kMeansColorRestrictions: Array = []; 15 | 16 | public colorAliases: { [key: string]: RGB } = {}; 17 | 18 | public narrowPixelStripCleanupRuns: number = 3; // 3 seems like a good compromise between removing enough narrow pixel strips to convergence. This fixes e.g. https://i.imgur.com/dz4ANz1.png 19 | 20 | public removeFacetsSmallerThanNrOfPoints: number = 20; 21 | public removeFacetsFromLargeToSmall: boolean = true; 22 | public maximumNumberOfFacets: number = Number.MAX_VALUE; 23 | 24 | public nrOfTimesToHalveBorderSegments: number = 2; 25 | 26 | public resizeImageIfTooLarge: boolean = true; 27 | public resizeImageWidth: number = 1024; 28 | public resizeImageHeight: number = 1024; 29 | 30 | public randomSeed: number = new Date().getTime(); 31 | } 32 | -------------------------------------------------------------------------------- /src/structs/typedarrays.ts: -------------------------------------------------------------------------------- 1 | 2 | export class Uint32Array2D { 3 | private arr: Uint32Array; 4 | constructor(private width: number, private height: number) { 5 | this.arr = new Uint32Array(width * height); 6 | } 7 | 8 | public get(x: number, y: number) { 9 | return this.arr[y * this.width + x]; 10 | } 11 | public set(x: number, y: number, value: number) { 12 | this.arr[y * this.width + x] = value; 13 | } 14 | } 15 | 16 | export class Uint8Array2D { 17 | private arr: Uint8Array; 18 | constructor(private width: number, private height: number) { 19 | this.arr = new Uint8Array(width * height); 20 | } 21 | public get(x: number, y: number) { 22 | return this.arr[y * this.width + x]; 23 | } 24 | 25 | public set(x: number, y: number, value: number) { 26 | this.arr[y * this.width + x] = value; 27 | } 28 | 29 | public matchAllAround(x: number, y: number, value: number) { 30 | const idx = y * this.width + x; 31 | return (x - 1 >= 0 && this.arr[idx - 1] === value) && 32 | (y - 1 >= 0 && this.arr[idx - this.width] === value) && 33 | (x + 1 < this.width && this.arr[idx + 1] === value) && 34 | (y + 1 < this.height && this.arr[idx + this.width] === value); 35 | } 36 | } 37 | 38 | export class BooleanArray2D { 39 | private arr: Uint8Array; 40 | constructor(private width: number, private height: number) { 41 | this.arr = new Uint8Array(width * height); 42 | } 43 | 44 | public get(x: number, y: number) { 45 | return this.arr[y * this.width + x] !== 0; 46 | } 47 | public set(x: number, y: number, value: boolean) { 48 | this.arr[y * this.width + x] = value ? 1 : 0; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CLI build 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: windows-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [10.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - run: npm ci 25 | name: "Install dependencies from package-lock.json" 26 | - run: npm install pkg -g 27 | name: "Install pkg" 28 | - run: npm install typescript -g 29 | name: "Install typescript" 30 | - run: tsc 31 | working-directory: ./src-cli 32 | name: "Build cli to javascript" 33 | - run: pkg . 34 | name: "Package cli to single executable" 35 | 36 | - run: mkdir "out" 37 | name: "Create output directory" 38 | - run: copy paint-by-numbers-generator-win.exe .\out\paint-by-numbers-generator-win.exe 39 | name: "Copy binary to out directory" 40 | - run: copy .\node_modules\canvas\build\Release\* .\out 41 | name: "Copy node-canvas binaries to output" 42 | - run: del *.pdb, *.ipdb, *.iobj 43 | name: "Cleanup unnecessary debug files" 44 | working-directory: ./out 45 | - uses: actions/upload-artifact@v2 46 | name: "Upload out as artifact" 47 | with: 48 | name: pbn-cli-win 49 | path: .\out\* 50 | env: 51 | CI: true 52 | - run: mkdir artifacts 53 | - name: Download a Build Artifact zip 54 | uses: actions/download-artifact@v2.0.5 55 | with: 56 | name: "pbn-cli-win" 57 | path: "./artifacts/pbn-cli-win.zip" 58 | - uses: ncipollo/release-action@v1 59 | name: "Create development prerelease" 60 | with: 61 | artifact: "artifacts/*" 62 | prerelease: true 63 | allowUpdates: true 64 | name: "CLI Development build" 65 | tag: "latest" 66 | token: ${{ secrets.GITHUB_TOKEN }} 67 | 68 | -------------------------------------------------------------------------------- /src-cli/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "randomSeed": 7707, 3 | "kMeansNrOfClusters": 16, 4 | "kMeansMinDeltaDifference": 1, 5 | "kMeansClusteringColorSpace": 0, 6 | "kMeansColorRestrictions": [], 7 | "colorAliases": { 8 | "A1": [ 0, 0, 0 ], 9 | "A2": [ 255, 0, 0 ], 10 | "A3": [ 0, 255, 0 ], 11 | "A4": [ 0, 0, 255 ], 12 | "B1": [ 64, 64, 64 ], 13 | "B2": [ 128, 128, 128 ], 14 | "B3": [ 192, 192, 192 ] 15 | }, 16 | "removeFacetsSmallerThanNrOfPoints": 20, 17 | "removeFacetsFromLargeToSmall": true, 18 | "maximumNumberOfFacets": 100, 19 | "nrOfTimesToHalveBorderSegments": 2, 20 | "narrowPixelStripCleanupRuns": 3, 21 | "resizeImageIfTooLarge": true, 22 | "resizeImageWidth": 1024, 23 | "resizeImageHeight": 1024, 24 | "outputProfiles": [ 25 | { 26 | "name": "full", 27 | "svgShowLabels": true, 28 | "svgFillFacets": true, 29 | "svgShowBorders": true, 30 | "svgSizeMultiplier": 3, 31 | "svgFontSize": 50, 32 | "svgFontColor": "#333", 33 | "filetype": "png" 34 | }, 35 | { 36 | "name": "bordersLabels", 37 | "svgShowLabels": true, 38 | "svgFillFacets": false, 39 | "svgShowBorders": true, 40 | "svgSizeMultiplier": 3, 41 | "svgFontSize": 50, 42 | "svgFontColor": "#333", 43 | "filetype": "svg" 44 | }, 45 | { 46 | "name": "jpgtest", 47 | "svgShowLabels": false, 48 | "svgFillFacets": true, 49 | "svgShowBorders": false, 50 | "svgSizeMultiplier": 3, 51 | "svgFontSize": 50, 52 | "svgFontColor": "#333", 53 | "filetype": "jpg", 54 | "filetypeQuality": 80 55 | } 56 | ] 57 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { downloadPalettePng, downloadPNG, downloadSVG, loadExample, process, updateOutput } from "./gui"; 2 | import { Clipboard } from "./lib/clipboard"; 3 | 4 | $(document).ready(function () { 5 | 6 | $(".tabs").tabs(); 7 | $(".tooltipped").tooltip(); 8 | 9 | const clip = new Clipboard("canvas", true); 10 | 11 | $("#file").change(function (ev) { 12 | const files = ($("#file").get(0)).files; 13 | if (files !== null && files.length > 0) { 14 | const reader = new FileReader(); 15 | reader.onloadend = function () { 16 | const img = document.createElement("img"); 17 | img.onload = () => { 18 | const c = document.getElementById("canvas") as HTMLCanvasElement; 19 | const ctx = c.getContext("2d")!; 20 | c.width = img.naturalWidth; 21 | c.height = img.naturalHeight; 22 | ctx.drawImage(img, 0, 0); 23 | }; 24 | img.onerror = () => { 25 | alert("Unable to load image"); 26 | } 27 | img.src = reader.result; 28 | } 29 | reader.readAsDataURL(files[0]); 30 | } 31 | }); 32 | 33 | loadExample("imgSmall"); 34 | 35 | $("#btnProcess").click(async function () { 36 | try { 37 | await process(); 38 | } catch (err) { 39 | alert("Error: " + err); 40 | } 41 | }); 42 | 43 | $("#chkShowLabels, #chkFillFacets, #chkShowBorders, #txtSizeMultiplier, #txtLabelFontSize, #txtLabelFontColor").change(async () => { 44 | await updateOutput(); 45 | }); 46 | 47 | $("#btnDownloadSVG").click(function () { 48 | downloadSVG(); 49 | }); 50 | 51 | $("#btnDownloadPNG").click(function () { 52 | downloadPNG(); 53 | }); 54 | 55 | $("#btnDownloadPalettePNG").click(function () { 56 | downloadPalettePng(); 57 | }); 58 | 59 | $("#lnkTrivial").click(() => { loadExample("imgTrivial"); return false; }); 60 | $("#lnkSmall").click(() => { loadExample("imgSmall"); return false; }); 61 | $("#lnkMedium").click(() => { loadExample("imgMedium"); return false; }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/lib/clustering.ts: -------------------------------------------------------------------------------- 1 | import { Random } from "../random"; 2 | 3 | export class Vector { 4 | 5 | public tag:any; 6 | 7 | constructor(public values: number[], public weight: number = 1) { } 8 | 9 | public distanceTo(p: Vector): number { 10 | let sumSquares = 0; 11 | for (let i: number = 0; i < this.values.length; i++) { 12 | sumSquares += (p.values[i] - this.values[i]) * (p.values[i] - this.values[i]); 13 | } 14 | 15 | return Math.sqrt(sumSquares); 16 | } 17 | 18 | /** 19 | * Calculates the weighted average of the given points 20 | */ 21 | public static average(pts: Vector[]): Vector { 22 | if (pts.length === 0) { 23 | throw Error("Can't average 0 elements"); 24 | } 25 | 26 | const dims = pts[0].values.length; 27 | const values = []; 28 | for (let i: number = 0; i < dims; i++) { 29 | values.push(0); 30 | } 31 | 32 | let weightSum = 0; 33 | for (const p of pts) { 34 | weightSum += p.weight; 35 | 36 | for (let i: number = 0; i < dims; i++) { 37 | values[i] += p.weight * p.values[i]; 38 | } 39 | } 40 | 41 | for (let i: number = 0; i < values.length; i++) { 42 | values[i] /= weightSum; 43 | } 44 | 45 | return new Vector(values); 46 | } 47 | } 48 | 49 | export class KMeans { 50 | 51 | public currentIteration: number = 0; 52 | public pointsPerCategory: Vector[][] = []; 53 | 54 | public centroids: Vector[] = []; 55 | public currentDeltaDistanceDifference: number = 0; 56 | 57 | constructor(private points: Vector[], public k: number, private random:Random, centroids: Vector[] | null = null) { 58 | 59 | if (centroids != null) { 60 | this.centroids = centroids; 61 | for (let i: number = 0; i < this.k; i++) { 62 | this.pointsPerCategory.push([]); 63 | } 64 | } else { 65 | this.initCentroids(); 66 | } 67 | } 68 | 69 | private initCentroids() { 70 | for (let i: number = 0; i < this.k; i++) { 71 | this.centroids.push(this.points[Math.floor(this.points.length * this.random.next())]); 72 | this.pointsPerCategory.push([]); 73 | } 74 | } 75 | 76 | public step() { 77 | // clear category 78 | for (let i: number = 0; i < this.k; i++) { 79 | this.pointsPerCategory[i] = []; 80 | } 81 | 82 | // calculate points per centroid 83 | for (const p of this.points) { 84 | let minDist = Number.MAX_VALUE; 85 | let centroidIndex: number = -1; 86 | for (let k: number = 0; k < this.k; k++) { 87 | const dist = this.centroids[k].distanceTo(p); 88 | if (dist < minDist) { 89 | centroidIndex = k; 90 | minDist = dist; 91 | 92 | } 93 | } 94 | this.pointsPerCategory[centroidIndex].push(p); 95 | } 96 | 97 | let totalDistanceDiff = 0; 98 | 99 | // adjust centroids 100 | for (let k: number = 0; k < this.pointsPerCategory.length; k++) { 101 | const cat = this.pointsPerCategory[k]; 102 | if (cat.length > 0) { 103 | const avg = Vector.average(cat); 104 | 105 | const dist = this.centroids[k].distanceTo(avg); 106 | totalDistanceDiff += dist; 107 | this.centroids[k] = avg; 108 | } 109 | } 110 | this.currentDeltaDistanceDifference = totalDistanceDiff; 111 | 112 | this.currentIteration++; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/lib/colorconversion.ts: -------------------------------------------------------------------------------- 1 | // From https://stackoverflow.com/a/9493060/694640 2 | 3 | /** 4 | * Converts an RGB color value to HSL. Conversion formula 5 | * adapted from http://en.wikipedia.org/wiki/HSL_color_space. 6 | * Assumes r, g, and b are contained in the set [0, 255] and 7 | * returns h, s, and l in the set [0, 1]. 8 | * 9 | * @param Number r The red color value 10 | * @param Number g The green color value 11 | * @param Number b The blue color value 12 | * @return Array The HSL representation 13 | */ 14 | export function rgbToHsl(r: number, g: number, b: number) { 15 | r /= 255, g /= 255, b /= 255; 16 | const max = Math.max(r, g, b); 17 | const min = Math.min(r, g, b); 18 | let h, s, l = (max + min) / 2; 19 | 20 | if (max === min) { 21 | h = s = 0; // achromatic 22 | } else { 23 | const d = max - min; 24 | s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 25 | switch (max) { 26 | case r: h = (g - b) / d + (g < b ? 6 : 0); break; 27 | case g: h = (b - r) / d + 2; break; 28 | case b: h = (r - g) / d + 4; break; 29 | default: h = 0; 30 | } 31 | h /= 6; 32 | } 33 | 34 | return [h, s, l]; 35 | } 36 | 37 | /** 38 | * Converts an HSL color value to RGB. Conversion formula 39 | * adapted from http://en.wikipedia.org/wiki/HSL_color_space. 40 | * Assumes h, s, and l are contained in the set [0, 1] and 41 | * returns r, g, and b in the set [0, 255]. 42 | * 43 | * @param Number h The hue 44 | * @param Number s The saturation 45 | * @param Number l The lightness 46 | * @return Array The RGB representation 47 | */ 48 | export function hslToRgb(h: number, s: number, l: number) { 49 | let r, g, b; 50 | 51 | if (s === 0) { 52 | r = g = b = l; // achromatic 53 | } else { 54 | const hue2rgb = (p: number, q: number, t: number) => { 55 | if (t < 0) { t += 1; } 56 | if (t > 1) { t -= 1; } 57 | if (t < 1 / 6) { return p + (q - p) * 6 * t; } 58 | if (t < 1 / 2) { return q; } 59 | if (t < 2 / 3) { return p + (q - p) * (2 / 3 - t) * 6; } 60 | return p; 61 | }; 62 | 63 | const q = l < 0.5 ? l * (1 + s) : l + s - l * s; 64 | const p = 2 * l - q; 65 | r = hue2rgb(p, q, h + 1 / 3); 66 | g = hue2rgb(p, q, h); 67 | b = hue2rgb(p, q, h - 1 / 3); 68 | } 69 | 70 | return [r * 255, g * 255, b * 255]; 71 | } 72 | 73 | // From https://github.com/antimatter15/rgb-lab/blob/master/color.js 74 | 75 | export function lab2rgb(lab: number[]) { 76 | let y = (lab[0] + 16) / 116, 77 | x = lab[1] / 500 + y, 78 | z = y - lab[2] / 200, 79 | r, g, b; 80 | 81 | x = 0.95047 * ((x * x * x > 0.008856) ? x * x * x : (x - 16 / 116) / 7.787); 82 | y = 1.00000 * ((y * y * y > 0.008856) ? y * y * y : (y - 16 / 116) / 7.787); 83 | z = 1.08883 * ((z * z * z > 0.008856) ? z * z * z : (z - 16 / 116) / 7.787); 84 | 85 | r = x * 3.2406 + y * -1.5372 + z * -0.4986; 86 | g = x * -0.9689 + y * 1.8758 + z * 0.0415; 87 | b = x * 0.0557 + y * -0.2040 + z * 1.0570; 88 | 89 | r = (r > 0.0031308) ? (1.055 * Math.pow(r, 1 / 2.4) - 0.055) : 12.92 * r; 90 | g = (g > 0.0031308) ? (1.055 * Math.pow(g, 1 / 2.4) - 0.055) : 12.92 * g; 91 | b = (b > 0.0031308) ? (1.055 * Math.pow(b, 1 / 2.4) - 0.055) : 12.92 * b; 92 | 93 | return [Math.max(0, Math.min(1, r)) * 255, 94 | Math.max(0, Math.min(1, g)) * 255, 95 | Math.max(0, Math.min(1, b)) * 255]; 96 | } 97 | 98 | export function rgb2lab(rgb: number[]) { 99 | let r = rgb[0] / 255, 100 | g = rgb[1] / 255, 101 | b = rgb[2] / 255, 102 | x, y, z; 103 | 104 | r = (r > 0.04045) ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92; 105 | g = (g > 0.04045) ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92; 106 | b = (b > 0.04045) ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92; 107 | 108 | x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047; 109 | y = (r * 0.2126 + g * 0.7152 + b * 0.0722) / 1.00000; 110 | z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883; 111 | 112 | x = (x > 0.008856) ? Math.pow(x, 1 / 3) : (7.787 * x) + 16 / 116; 113 | y = (y > 0.008856) ? Math.pow(y, 1 / 3) : (7.787 * y) + 16 / 116; 114 | z = (z > 0.008856) ? Math.pow(z, 1 / 3) : (7.787 * z) + 16 / 116; 115 | 116 | return [(116 * y) - 16, 500 * (x - y), 200 * (y - z)]; 117 | } 118 | -------------------------------------------------------------------------------- /src/facetLabelPlacer.ts: -------------------------------------------------------------------------------- 1 | import { delay } from "./common"; 2 | import { pointToPolygonDist, polylabel } from "./lib/polylabel"; 3 | import { BoundingBox } from "./structs/boundingbox"; 4 | import { Point } from "./structs/point"; 5 | import { FacetResult, Facet } from "./facetmanagement"; 6 | import { FacetCreator } from "./facetCreator"; 7 | 8 | 9 | export class FacetLabelPlacer { 10 | /** 11 | * Determines where to place the labels for each facet. This is done by calculating where 12 | * in the polygon the largest circle can be contained, also called the pole of inaccessibility 13 | * That's the spot where there will be the most room for the label. 14 | * One tricky gotcha: neighbour facets can lay completely inside other facets and can overlap the label 15 | * if only the outer border of the facet is taken in account. This is solved by adding the neighbours facet polygon that fall 16 | * within the facet as additional polygon rings (why does everything look so easy to do yet never is under the hood :/) 17 | */ 18 | public static async buildFacetLabelBounds(facetResult: FacetResult, onUpdate: ((progress: number) => void) | null = null) { 19 | let count = 0; 20 | for (const f of facetResult.facets) { 21 | if (f != null) { 22 | const polyRings: Point[][] = []; 23 | // get the border path from the segments (that can have been reduced compared to facet actual border path) 24 | const borderPath = f.getFullPathFromBorderSegments(true); 25 | // outer path must be first ring 26 | polyRings.push(borderPath); 27 | const onlyOuterRing = [borderPath]; 28 | // now add all the neighbours of the facet as "inner" rings, 29 | // regardless if they are inner or not. These are seen as areas where the label 30 | // cannot be placed 31 | if (f.neighbourFacetsIsDirty) { 32 | FacetCreator.buildFacetNeighbour(f, facetResult); 33 | } 34 | for (const neighbourIdx of f.neighbourFacets!) { 35 | const neighbourPath = facetResult.facets[neighbourIdx]!.getFullPathFromBorderSegments(true); 36 | const fallsInside: boolean = FacetLabelPlacer.doesNeighbourFallInsideInCurrentFacet(neighbourPath, f, onlyOuterRing); 37 | if (fallsInside) { 38 | polyRings.push(neighbourPath); 39 | } 40 | } 41 | const result = polylabel(polyRings); 42 | f.labelBounds = new BoundingBox(); 43 | // determine inner square within the circle 44 | const innerPadding = 2 * Math.sqrt(2 * result.distance); 45 | f.labelBounds.minX = result.pt.x - innerPadding; 46 | f.labelBounds.maxX = result.pt.x + innerPadding; 47 | f.labelBounds.minY = result.pt.y - innerPadding; 48 | f.labelBounds.maxY = result.pt.y + innerPadding; 49 | if (count % 100 === 0) { 50 | await delay(0); 51 | if (onUpdate != null) { 52 | onUpdate(f.id / facetResult.facets.length); 53 | } 54 | } 55 | } 56 | count++; 57 | } 58 | if (onUpdate != null) { 59 | onUpdate(1); 60 | } 61 | } 62 | 63 | /** 64 | * Checks whether a neighbour border path is fully within the current facet border path 65 | */ 66 | private static doesNeighbourFallInsideInCurrentFacet(neighbourPath: Point[], f: Facet, onlyOuterRing: Point[][]) { 67 | let fallsInside: boolean = true; 68 | // fast test to see if the neighbour falls inside the bbox of the facet 69 | for (let i: number = 0; i < neighbourPath.length && fallsInside; i++) { 70 | if (neighbourPath[i].x >= f.bbox.minX && neighbourPath[i].x <= f.bbox.maxX && 71 | neighbourPath[i].y >= f.bbox.minY && neighbourPath[i].y <= f.bbox.maxY) { 72 | // ok 73 | } 74 | else { 75 | fallsInside = false; 76 | } 77 | } 78 | if (fallsInside) { 79 | // do a more fine grained but more expensive check to see if each of the points fall within the polygon 80 | for (let i: number = 0; i < neighbourPath.length && fallsInside; i++) { 81 | const distance = pointToPolygonDist(neighbourPath[i].x, neighbourPath[i].y, onlyOuterRing); 82 | if (distance < 0) { 83 | // falls outside 84 | fallsInside = false; 85 | } 86 | } 87 | } 88 | return fallsInside; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/lib/fill.ts: -------------------------------------------------------------------------------- 1 | 2 | // Faster flood fill from 3 | // http://www.adammil.net/blog/v126_A_More_Efficient_Flood_Fill.html 4 | 5 | export function fill(x: number, y: number, width: number, height: number, visited: ((i: number, j: number) => boolean), setFill: ((i: number, j: number) => void)) { 6 | 7 | // at this point, we know array[y,x] is clear, and we want to move as far as possible to the upper-left. moving 8 | // up is much more important than moving left, so we could try to make this smarter by sometimes moving to 9 | // the right if doing so would allow us to move further up, but it doesn't seem worth the complexit 10 | let xx = x; 11 | let yy = y; 12 | while (true) { 13 | const ox = xx; 14 | const oy = yy; 15 | while (yy !== 0 && !visited(xx, yy - 1)) { yy--; } 16 | while (xx !== 0 && !visited(xx - 1, yy)) { xx--; } 17 | if (xx === ox && yy === oy) { break; } 18 | } 19 | fillCore(xx, yy, width, height, visited, setFill); 20 | 21 | } 22 | 23 | function fillCore(x: number, y: number, width: number, height: number, visited: ((i: number, j: number) => boolean), setFill: ((i: number, j: number) => void)) { 24 | 25 | // at this point, we know that array[y,x] is clear, and array[y-1,x] and array[y,x-1] are set. 26 | // we'll begin scanning down and to the right, attempting to fill an entire rectangular block 27 | let lastRowLength = 0; // the number of cells that were clear in the last row we scanned 28 | do { 29 | let rowLength = 0; 30 | let sx = x; // keep track of how long this row is. sx is the starting x for the main scan below 31 | // now we want to handle a case like |***|, where we fill 3 cells in the first row and then after we move to 32 | // the second row we find the first | **| cell is filled, ending our rectangular scan. rather than handling 33 | // this via the recursion below, we'll increase the starting value of 'x' and reduce the last row length to 34 | // match. then we'll continue trying to set the narrower rectangular block 35 | if (lastRowLength !== 0 && visited(x, y)) { 36 | do { 37 | if (--lastRowLength === 0) { return; } // shorten the row. if it's full, we're done 38 | } while (visited(++x, y)); // otherwise, update the starting point of the main scan to match 39 | sx = x; 40 | } else { 41 | for (; x !== 0 && !visited(x - 1, y); rowLength++ , lastRowLength++) { 42 | x--; 43 | setFill(x, y); // to avoid scanning the cells twice, we'll fill them and update rowLength here 44 | // if there's something above the new starting point, handle that recursively. this deals with cases 45 | // like |* **| when we begin filling from (2,0), move down to (2,1), and then move left to (0,1). 46 | // the |****| main scan assumes the portion of the previous row from x to x+lastRowLength has already 47 | // been filled. adjusting x and lastRowLength breaks that assumption in this case, so we must fix it 48 | if (y !== 0 && !visited(x, y - 1)) { fill(x, y - 1, width, height, visited, setFill); } // use _Fill since there may be more up and left 49 | } 50 | } 51 | 52 | // now at this point we can begin to scan the current row in the rectangular block. the span of the previous 53 | // row from x (inclusive) to x+lastRowLength (exclusive) has already been filled, so we don't need to 54 | // check it. so scan across to the right in the current row 55 | for (; sx < width && !visited(sx, y); rowLength++ , sx++) { setFill(sx, y); } 56 | // now we've scanned this row. if the block is rectangular, then the previous row has already been scanned, 57 | // so we don't need to look upwards and we're going to scan the next row in the next iteration so we don't 58 | // need to look downwards. however, if the block is not rectangular, we may need to look upwards or rightwards 59 | // for some portion of the row. if this row was shorter than the last row, we may need to look rightwards near 60 | // the end, as in the case of |*****|, where the first row is 5 cells long and the second row is 3 cells long. 61 | // we must look to the right |*** *| of the single cell at the end of the second row, i.e. at (4,1) 62 | if (rowLength < lastRowLength) { 63 | for (const end = x + lastRowLength; ++sx < end;) { // there. any clear cells would have been connected to the previous 64 | if (!visited(sx, y)) { fillCore(sx, y, width, height, visited, setFill); } // row. the cells up and left must be set so use FillCore 65 | } 66 | } else if (rowLength > lastRowLength && y !== 0) { 67 | for (let ux = x + lastRowLength; ++ux < sx;) { 68 | if (!visited(ux, y - 1)) { fill(ux, y - 1, width, height, visited, setFill); } // since there may be clear cells up and left, use _Fill 69 | } 70 | } 71 | lastRowLength = rowLength; // record the new row length 72 | } while (lastRowLength !== 0 && ++y < height); // if we get to a full row or to the bottom, we're done 73 | 74 | } 75 | -------------------------------------------------------------------------------- /src/facetmanagement.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Facet management from the process, anything from construction, reduction and border tracing etc. 3 | */ 4 | import { FacetBoundarySegment } from "./FacetBorderSegmenter"; 5 | import { BoundingBox } from "./structs/boundingbox"; 6 | import { Point } from "./structs/point"; 7 | import { Uint32Array2D } from "./structs/typedarrays"; 8 | 9 | export enum OrientationEnum { 10 | Left, 11 | Top, 12 | Right, 13 | Bottom, 14 | } 15 | 16 | /** 17 | * PathPoint is a point with an orientation that indicates which wall border is set 18 | */ 19 | export class PathPoint extends Point { 20 | 21 | constructor(pt: Point, public orientation: OrientationEnum) { 22 | super(pt.x, pt.y); 23 | } 24 | 25 | public getWallX() { 26 | let x = this.x; 27 | if (this.orientation === OrientationEnum.Left) { 28 | x -= 0.5; 29 | } else if (this.orientation === OrientationEnum.Right) { 30 | x += 0.5; 31 | } 32 | return x; 33 | } 34 | 35 | public getWallY() { 36 | let y = this.y; 37 | if (this.orientation === OrientationEnum.Top) { 38 | y -= 0.5; 39 | } else if (this.orientation === OrientationEnum.Bottom) { 40 | y += 0.5; 41 | } 42 | return y; 43 | } 44 | 45 | public getNeighbour(facetResult: FacetResult) { 46 | switch (this.orientation) { 47 | case OrientationEnum.Left: 48 | if (this.x - 1 >= 0) { 49 | return facetResult.facetMap.get(this.x - 1, this.y); 50 | } 51 | break; 52 | case OrientationEnum.Right: 53 | if (this.x + 1 < facetResult.width) { 54 | return facetResult.facetMap.get(this.x + 1, this.y); 55 | } 56 | break; 57 | case OrientationEnum.Top: 58 | if (this.y - 1 >= 0) { 59 | return facetResult.facetMap.get(this.x, this.y - 1); 60 | } 61 | break; 62 | case OrientationEnum.Bottom: 63 | if (this.y + 1 < facetResult.height) { 64 | return facetResult.facetMap.get(this.x, this.y + 1); 65 | } 66 | break; 67 | } 68 | return -1; 69 | } 70 | 71 | public toString() { 72 | return this.x + "," + this.y + " " + this.orientation; 73 | } 74 | } 75 | 76 | /** 77 | * A facet that represents an area of pixels of the same color 78 | */ 79 | export class Facet { 80 | 81 | /** 82 | * The id of the facet, is always the same as the actual index of the facet in the facet array 83 | */ 84 | public id!: number; 85 | public color!: number; 86 | public pointCount: number = 0; 87 | public borderPoints!: Point[]; 88 | public neighbourFacets!: number[] | null; 89 | /** 90 | * Flag indicating if the neighbourfacets array is dirty. If it is, the neighbourfacets *have* to be rebuild 91 | * Before it can be used. This is useful to defer the rebuilding of the array until it's actually needed 92 | * and can remove a lot of duplicate building of the array because multiple facets were hitting the same neighbour 93 | * (over 50% on test images) 94 | */ 95 | public neighbourFacetsIsDirty: boolean = false; 96 | 97 | public bbox!: BoundingBox; 98 | 99 | public borderPath!: PathPoint[]; 100 | public borderSegments!: FacetBoundarySegment[]; 101 | 102 | public labelBounds!: BoundingBox; 103 | 104 | public getFullPathFromBorderSegments(useWalls: boolean) { 105 | const newpath: Point[] = []; 106 | 107 | const addPoint = (pt: PathPoint) => { 108 | if (useWalls) { 109 | newpath.push(new Point(pt.getWallX(), pt.getWallY())); 110 | } else { 111 | newpath.push(new Point(pt.x, pt.y)); 112 | } 113 | }; 114 | 115 | let lastSegment: FacetBoundarySegment | null = null; 116 | for (const seg of this.borderSegments) { 117 | 118 | // fix for the continuitity of the border segments. If transition points between border segments on the path aren't repeated, the 119 | // borders of the facets aren't always matching up leaving holes when rendered 120 | if (lastSegment != null) { 121 | if (lastSegment.reverseOrder) { 122 | addPoint(lastSegment.originalSegment.points[0]); 123 | } else { 124 | addPoint(lastSegment.originalSegment.points[lastSegment.originalSegment.points.length - 1]); 125 | } 126 | } 127 | 128 | for (let i: number = 0; i < seg.originalSegment.points.length; i++) { 129 | const idx = seg.reverseOrder ? (seg.originalSegment.points.length - 1 - i) : i; 130 | addPoint(seg.originalSegment.points[idx]); 131 | } 132 | 133 | lastSegment = seg; 134 | } 135 | return newpath; 136 | } 137 | 138 | } 139 | 140 | /** 141 | * Result of the facet construction, both as a map and as an array. 142 | * Facets in the array can be null when they've been deleted 143 | */ 144 | export class FacetResult { 145 | public facetMap!: Uint32Array2D; 146 | public facets!: Array; 147 | 148 | public width!: number; 149 | public height!: number; 150 | } 151 | -------------------------------------------------------------------------------- /src/lib/polylabel.ts: -------------------------------------------------------------------------------- 1 | import { IComparable, IHeapItem, PriorityQueue } from "./datastructs"; 2 | 3 | // This is a typescript port of https://github.com/mapbox/polylabel to calculate the pole of inaccessibility quickly 4 | 5 | type Polygon = PolygonRing[]; 6 | type PolygonRing = Point[]; 7 | 8 | interface Point { 9 | x: number; 10 | y: number; 11 | } 12 | interface PointResult { 13 | pt: Point; 14 | distance: number; 15 | } 16 | 17 | export function polylabel(polygon: Polygon, precision: number = 1.0): PointResult { 18 | 19 | // find the bounding box of the outer ring 20 | let minX = Number.MAX_VALUE; 21 | let minY = Number.MAX_VALUE; 22 | let maxX = Number.MIN_VALUE; 23 | let maxY = Number.MIN_VALUE; 24 | 25 | for (let i = 0; i < polygon[0].length; i++) { 26 | const p = polygon[0][i]; 27 | if (p.x < minX) { minX = p.x; } 28 | if (p.y < minY) { minY = p.y; } 29 | if (p.x > maxX) { maxX = p.x; } 30 | if (p.y > maxY) { maxY = p.y; } 31 | } 32 | 33 | const width = maxX - minX; 34 | const height = maxY - minY; 35 | const cellSize = Math.min(width, height); 36 | let h = cellSize / 2; 37 | 38 | // a priority queue of cells in order of their "potential" (max distance to polygon) 39 | const cellQueue = new PriorityQueue(); 40 | 41 | if (cellSize === 0) { return { pt: { x: minX, y: minY }, distance: 0 }; } 42 | 43 | // cover polygon with initial cells 44 | for (let x = minX; x < maxX; x += cellSize) { 45 | for (let y = minY; y < maxY; y += cellSize) { 46 | cellQueue.enqueue(new Cell(x + h, y + h, h, polygon)); 47 | } 48 | } 49 | 50 | // take centroid as the first best guess 51 | let bestCell = getCentroidCell(polygon); 52 | 53 | // special case for rectangular polygons 54 | const bboxCell = new Cell(minX + width / 2, minY + height / 2, 0, polygon); 55 | if (bboxCell.d > bestCell.d) { bestCell = bboxCell; } 56 | 57 | let numProbes = cellQueue.size; 58 | 59 | while (cellQueue.size > 0) { 60 | // pick the most promising cell from the queue 61 | const cell = cellQueue.dequeue(); 62 | 63 | // update the best cell if we found a better one 64 | if (cell.d > bestCell.d) { 65 | bestCell = cell; 66 | } 67 | 68 | // do not drill down further if there's no chance of a better solution 69 | if (cell.max - bestCell.d <= precision) { continue; } 70 | 71 | // split the cell into four cells 72 | h = cell.h / 2; 73 | cellQueue.enqueue(new Cell(cell.x - h, cell.y - h, h, polygon)); 74 | cellQueue.enqueue(new Cell(cell.x + h, cell.y - h, h, polygon)); 75 | cellQueue.enqueue(new Cell(cell.x - h, cell.y + h, h, polygon)); 76 | cellQueue.enqueue(new Cell(cell.x + h, cell.y + h, h, polygon)); 77 | numProbes += 4; 78 | } 79 | 80 | return { pt: { x: bestCell.x, y: bestCell.y }, distance: bestCell.d }; 81 | } 82 | 83 | class Cell implements IHeapItem { 84 | public x: number; // cell center x 85 | public y: number; // cell center y 86 | public h: number; // half the cell size 87 | public d: number; // distance from cell center to polygon 88 | public max: number; // max distance to polygon within a cell 89 | constructor(x: number, y: number, h: number, polygon: Polygon) { 90 | this.x = x; 91 | this.y = y; 92 | this.h = h; 93 | this.d = pointToPolygonDist(x, y, polygon); 94 | this.max = this.d + this.h * Math.SQRT2; 95 | } 96 | 97 | public compareTo(other: IComparable): number { 98 | return (other as Cell).max - this.max; 99 | } 100 | 101 | public getKey() { 102 | return this.x + "," + this.y; 103 | } 104 | } 105 | 106 | // get squared distance from a point px,py to a segment [a-b] 107 | function getSegDistSq(px: number, py: number, a: Point, b: Point) { 108 | 109 | let x = a.x; 110 | let y = a.y; 111 | let dx = b.x - x; 112 | let dy = b.y - y; 113 | 114 | if (dx !== 0 || dy !== 0) { 115 | const t = ((px - x) * dx + (py - y) * dy) / (dx * dx + dy * dy); 116 | 117 | if (t > 1) { 118 | x = b.x; 119 | y = b.y; 120 | 121 | } else if (t > 0) { 122 | x += dx * t; 123 | y += dy * t; 124 | } 125 | } 126 | 127 | dx = px - x; 128 | dy = py - y; 129 | 130 | return dx * dx + dy * dy; 131 | } 132 | 133 | /** 134 | * Signed distance from point to polygon outline (negative if point is outside) 135 | */ 136 | export function pointToPolygonDist(x: number, y: number, polygon: Polygon): number { 137 | let inside = false; 138 | let minDistSq = Infinity; 139 | 140 | for (let k = 0; k < polygon.length; k++) { 141 | const ring = polygon[k]; 142 | 143 | for (let i = 0, len = ring.length, j = len - 1; i < len; j = i++) { 144 | const a = ring[i]; 145 | const b = ring[j]; 146 | 147 | if ((a.y > y !== b.y > y) && 148 | (x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x)) { inside = !inside; } 149 | 150 | minDistSq = Math.min(minDistSq, getSegDistSq(x, y, a, b)); 151 | } 152 | } 153 | 154 | return (inside ? 1 : -1) * Math.sqrt(minDistSq); 155 | } 156 | 157 | // get polygon centroid 158 | function getCentroidCell(polygon: Polygon) { 159 | let area = 0; 160 | let x = 0; 161 | let y = 0; 162 | const points = polygon[0]; 163 | 164 | for (let i = 0, len = points.length, j = len - 1; i < len; j = i++) { 165 | const a = points[i]; 166 | const b = points[j]; 167 | const f = a.x * b.y - b.x * a.y; 168 | x += (a.x + b.x) * f; 169 | y += (a.y + b.y) * f; 170 | area += f * 3; 171 | } 172 | if (area === 0) { return new Cell(points[0].x, points[0].y, 0, polygon); } 173 | return new Cell(x / area, y / area, 0, polygon); 174 | } 175 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Paint by numbers generator 2 | Generate paint by number images (vectorized with SVG) from any input image. 3 | 4 | *** This project was a proof of concept for fun back in the day, it is not being actively maintained but feel free to fork and make your own changes. *** 5 | 6 | ## Demo 7 | 8 | Try it out [here](https://drake7707.github.io/paintbynumbersgenerator/index.html) 9 | 10 | ### CLI Version 11 | 12 | The CLI version is a self contained node application that does the conversion from arguments, for example: 13 | ``` 14 | paint-by-numbers-generator-win.exe -i input.png -o output.svg 15 | ``` 16 | You can change the settings in settings.json or optionally specify a specific settings.json with the `-c path_to_settings.json` argument. 17 | 18 | The settings contain mostly the same settings in the web version: 19 | - randomSeed: the random seed to choose the initial starting points of the k-means clustering algorithm. This ensures that the same results are generated each time. 20 | - kMeansNrOfClusters: the number of colors to quantize the image to 21 | - kMeansMinDeltaDifference: the threshold delta distance of the k-means clustering to reach before stopping. Having a bigger value will speed up the clustering but may yield suboptimal clusters. Default 1 22 | - kMeansClusteringColorSpace: the color space to apply clustering in 23 | - kMeansColorRestrictions: Specify which colors should be used. An array of rgb values (as number array) or names of colors (reference to color aliases). If no colors are specified no restrictions are applied. Useful if you only have a few colors of paint on hand. 24 | - colorAliases: map of key/values where the keys are the color names and the values are the rgb colors (as number array). You can use the color names in the color restrictions above. The names are also mentioned in the output json that tells you how much % of the area is of that specific color. 25 | ``` 26 | "colorAliases": { 27 | "A1": [ 0, 0, 0 ], 28 | "A2": [ 255, 0, 0 ], 29 | "A3": [ 0, 255, 0 ], 30 | } 31 | ``` 32 | - removeFacetsSmallerThanNrOfPoints: removes any facets that are smaller than the given amount of pixels. Lowering the value will create more detailed results but might be much harder to actually paint due to their size. 33 | - removeFacetsFromLargeToSmall (true/false): largest to smallest will prevent boundaries from warping the shapes because the smaller facets act as border anchorpoints but can be considerably slower 34 | - maximumNumberOfFacets: if there are more facets than the given maximum number, keep removing the smallest facets until the limit is reached 35 | 36 | - nrOfTimesToHalveBorderSegments: reducing the amount of points in a border segment (using haar wavelet reduction) will smooth out the quadratic curve more but at a loss of detail. A segment (shared border with a facet) will always retain its start and end point. 37 | 38 | - narrowPixelStripCleanupRuns: narrow pixel cleanup removes strips of single pixel rows, which would make some facets have some borders segments that are way too narrow to be useful. The small facet removal can introduce new narrow pixel strips, so this is repeated in a few iterative runs. 39 | 40 | - resizeImageIfTooLarge (true/false): if true and the input image is larger than the given dimensions then it will be resized to fit but will maintain its ratio. 41 | - resizeImageWidth: width restriction 42 | - resizeImageHeight: height restriction 43 | 44 | There are also output profiles that you can define to output the result to svg, png, jpg with specific settings, for example: 45 | ``` 46 | "outputProfiles": [ 47 | { 48 | "name": "full", 49 | "svgShowLabels": true, 50 | "svgFillFacets": true, 51 | "svgShowBorders": true, 52 | "svgSizeMultiplier": 3, 53 | "svgFontSize": 50, 54 | "svgFontColor": "#333", 55 | "filetype": "png" 56 | }, 57 | { 58 | "name": "bordersLabels", 59 | "svgShowLabels": true, 60 | "svgFillFacets": false, 61 | "svgShowBorders": true, 62 | "svgSizeMultiplier": 3, 63 | "svgFontSize": 50, 64 | "svgFontColor": "#333", 65 | "filetype": "svg" 66 | }, 67 | { 68 | "name": "jpgtest", 69 | "svgShowLabels": false, 70 | "svgFillFacets": true, 71 | "svgShowBorders": false, 72 | "svgSizeMultiplier": 3, 73 | "svgFontSize": 50, 74 | "svgFontColor": "#333", 75 | "filetype": "jpg", 76 | "filetypeQuality": 80 77 | } 78 | ] 79 | ``` 80 | This defines 3 output profiles. The "full" profile shows labels, fills the facets and shows the borders with a 3x size multiplier, font size weight of 50, color of #333 and output to a png image. The bordersLabels profile outputs to a svg file without filling facets and jpgtest outputs to a jpg file with jpg quality setting of 80. 81 | 82 | The CLI version also outputs a json file that gives more information about the palette, which colors are used and in what quantity, e.g.: 83 | ``` 84 | ... 85 | { 86 | "areaPercentage": 0.20327615489130435, 87 | "color": [ 59, 36, 27 ], 88 | "frequency": 119689, 89 | "index": 0 90 | }, 91 | ... 92 | ``` 93 | 94 | The CLI version is useful if you want to automate the process into your own scripts. 95 | 96 | ## Screenshots 97 | 98 | ![Screenshot](https://i.imgur.com/6uHm78x.png]) 99 | 100 | ![Screenshot](https://i.imgur.com/cY9ieAy.png) 101 | 102 | 103 | ## Example output 104 | 105 | ![ExampleOutput](https://i.imgur.com/2Zuo13d.png) 106 | 107 | ![ExampleOutput2](https://i.imgur.com/SxWhOc7.png) 108 | 109 | ## Running locally 110 | 111 | I used VSCode, which has built in typescript support. To debug it uses a tiny webserver to host the files on localhost. 112 | 113 | To run do `npm install` to restore packages and then `npm start` to start the webserver 114 | 115 | 116 | ## Compiling the cli version 117 | 118 | Install pkg first if you don't have it yet `npm install pkg -g`. Then in the root folder run `pkg .`. This will generate the output for linux, windows and macos. 119 | -------------------------------------------------------------------------------- /src/lib/clipboard.ts: -------------------------------------------------------------------------------- 1 | // From https://stackoverflow.com/a/35576409/694640 2 | /** 3 | * image pasting into canvas 4 | * 5 | * @param {string} canvas_id - canvas id 6 | * @param {boolean} autoresize - if canvas will be resized 7 | */ 8 | export class Clipboard { 9 | 10 | private ctrl_pressed = false; 11 | private command_pressed = false; 12 | private pasteCatcher: any; 13 | private paste_event_support: boolean = false; 14 | private canvas: HTMLCanvasElement; 15 | private ctx: CanvasRenderingContext2D; 16 | private autoresize: boolean; 17 | 18 | constructor(canvas_id: string, autoresize: boolean) { 19 | const _self: any = this; 20 | this.canvas = document.getElementById(canvas_id); 21 | this.ctx = this.canvas.getContext("2d")!; 22 | this.autoresize = autoresize; 23 | 24 | // handlers 25 | // document.addEventListener("keydown", function (e) { 26 | // _self.on_keyboard_action(e); 27 | // }, false); // firefox fix 28 | // document.addEventListener("keyup", function (e) { 29 | // _self.on_keyboardup_action(e); 30 | // }, false); // firefox fix 31 | 32 | document.addEventListener("paste", function (e) { 33 | _self.paste_auto(e); 34 | }, false); // official paste handler 35 | 36 | this.init(); 37 | } 38 | 39 | // constructor - we ignore security checks here 40 | public init() { 41 | this.pasteCatcher = document.createElement("div"); 42 | this.pasteCatcher.setAttribute("id", "paste_ff"); 43 | this.pasteCatcher.setAttribute("contenteditable", ""); 44 | this.pasteCatcher.style.cssText = "opacity:0;position:fixed;top:0px;left:0px;width:10px;margin-left:-20px;"; 45 | document.body.appendChild(this.pasteCatcher); 46 | 47 | const _self = this; 48 | // create an observer instance 49 | const observer = new MutationObserver(function (mutations) { 50 | mutations.forEach(function (mutation) { 51 | if (_self.paste_event_support === true || _self.ctrl_pressed === false || mutation.type !== "childList") { 52 | // we already got data in paste_auto() 53 | return true; 54 | } 55 | 56 | // if paste handle failed - capture pasted object manually 57 | if (mutation.addedNodes.length === 1) { 58 | if ((mutation.addedNodes[0] as HTMLImageElement).src !== undefined) { 59 | // image 60 | _self.paste_createImage((mutation.addedNodes[0] as HTMLImageElement).src); 61 | } 62 | // register cleanup after some time. 63 | setTimeout(function () { 64 | _self.pasteCatcher.innerHTML = ""; 65 | }, 20); 66 | } 67 | 68 | return false; 69 | }); 70 | }); 71 | const target = document.getElementById("paste_ff"); 72 | const config = { attributes: true, childList: true, characterData: true }; 73 | observer.observe(target as any, config); 74 | } 75 | 76 | // default paste action 77 | private paste_auto(e: any) { 78 | this.paste_event_support = false; 79 | if (this.pasteCatcher !== undefined) { 80 | this.pasteCatcher.innerHTML = ""; 81 | } 82 | if (e.clipboardData) { 83 | const items = e.clipboardData.items; 84 | if (items) { 85 | this.paste_event_support = true; 86 | // access data directly 87 | for (let i = 0; i < items.length; i++) { 88 | if (items[i].type.indexOf("image") !== -1) { 89 | // image 90 | const blob = items[i].getAsFile(); 91 | const URLObj = window.URL || (window as any).webkitURL; 92 | const source = URLObj.createObjectURL(blob); 93 | this.paste_createImage(source); 94 | e.preventDefault(); 95 | return false; 96 | } 97 | } 98 | } else { 99 | // wait for DOMSubtreeModified event 100 | // https://bugzilla.mozilla.org/show_bug.cgi?id=891247 101 | } 102 | } 103 | return true; 104 | } 105 | 106 | // on keyboard press 107 | private on_keyboard_action(event: any) { 108 | const k = event.keyCode; 109 | // ctrl 110 | if (k === 17 || event.metaKey || event.ctrlKey) { 111 | if (this.ctrl_pressed === false) { 112 | this.ctrl_pressed = true; 113 | } 114 | } 115 | // v 116 | if (k === 86) { 117 | if (document.activeElement !== undefined && (document.activeElement as any).type === "text") { 118 | // let user paste into some input 119 | return false; 120 | } 121 | 122 | if (this.ctrl_pressed === true && this.pasteCatcher !== undefined) { 123 | this.pasteCatcher.focus(); 124 | } 125 | } 126 | 127 | return true; 128 | } 129 | 130 | // on keyboard release 131 | private on_keyboardup_action(event: any) { 132 | // ctrl 133 | if (event.ctrlKey === false && this.ctrl_pressed === true) { 134 | this.ctrl_pressed = false; 135 | } else if (event.metaKey === false && this.command_pressed === true) { 136 | this.command_pressed = false; 137 | this.ctrl_pressed = false; 138 | } 139 | } 140 | // draw pasted image to canvas 141 | private paste_createImage(source: any) { 142 | const pastedImage = new Image(); 143 | const self = this; 144 | pastedImage.onload = function () { 145 | if (self.autoresize === true) { 146 | // resize 147 | self.canvas.width = pastedImage.width; 148 | self.canvas.height = pastedImage.height; 149 | } else { 150 | // clear canvas 151 | self.ctx.clearRect(0, 0, self.canvas.width, self.canvas.height); 152 | } 153 | self.ctx.drawImage(pastedImage, 0, 0); 154 | }; 155 | pastedImage.src = source; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/facetCreator.ts: -------------------------------------------------------------------------------- 1 | import { delay, IMap, RGB } from "./common"; 2 | import { fill } from "./lib/fill"; 3 | import { BoundingBox } from "./structs/boundingbox"; 4 | import { Point } from "./structs/point"; 5 | import { BooleanArray2D, Uint32Array2D, Uint8Array2D } from "./structs/typedarrays"; 6 | import { FacetResult, Facet } from "./facetmanagement"; 7 | import { FacetReducer } from "./facetReducer"; 8 | 9 | export class FacetCreator { 10 | /** 11 | * Constructs the facets with its border points for each area of pixels of the same color 12 | */ 13 | public static async getFacets(width: number, height: number, imgColorIndices: Uint8Array2D, onUpdate: ((progress: number) => void) | null = null): Promise { 14 | const result = new FacetResult(); 15 | result.width = width; 16 | result.height = height; 17 | // setup visited mask 18 | const visited = new BooleanArray2D(result.width, result.height); 19 | // setup facet map & array 20 | result.facetMap = new Uint32Array2D(result.width, result.height); 21 | result.facets = []; 22 | // depth first traversal to find the different facets 23 | let count = 0; 24 | for (let j: number = 0; j < result.height; j++) { 25 | for (let i: number = 0; i < result.width; i++) { 26 | const colorIndex = imgColorIndices.get(i, j); 27 | if (!visited.get(i, j)) { 28 | const facetIndex = result.facets.length; 29 | // build a facet starting at point i,j 30 | const facet = FacetCreator.buildFacet(facetIndex, colorIndex, i, j, visited, imgColorIndices, result); 31 | result.facets.push(facet); 32 | if (count % 100 === 0) { 33 | await delay(0); 34 | if (onUpdate != null) { 35 | onUpdate(count / (result.width * result.height)); 36 | } 37 | } 38 | } 39 | count++; 40 | } 41 | } 42 | await delay(0); 43 | // fill in the neighbours of all facets by checking the neighbours of the border points 44 | for (const f of result.facets) { 45 | if (f != null) { 46 | FacetCreator.buildFacetNeighbour(f, result); 47 | } 48 | } 49 | if (onUpdate != null) { 50 | onUpdate(1); 51 | } 52 | return result; 53 | } 54 | 55 | /** 56 | * Builds a facet at given x,y using depth first search to visit all pixels of the same color 57 | */ 58 | public static buildFacet(facetIndex: number, facetColorIndex: number, x: number, y: number, visited: BooleanArray2D, imgColorIndices: Uint8Array2D, facetResult: FacetResult) { 59 | const facet = new Facet(); 60 | facet.id = facetIndex; 61 | facet.color = facetColorIndex; 62 | facet.bbox = new BoundingBox(); 63 | facet.borderPoints = []; 64 | 65 | facet.neighbourFacetsIsDirty = true; // not built neighbours yet 66 | facet.neighbourFacets = null; 67 | 68 | fill(x, y, facetResult.width, facetResult.height, (ptx, pty) => visited.get(ptx, pty) || imgColorIndices.get(ptx, pty) !== facetColorIndex, (ptx, pty) => { 69 | visited.set(ptx, pty, true); 70 | facetResult.facetMap.set(ptx, pty, facetIndex); 71 | facet.pointCount++; 72 | // determine if the point is a border or not 73 | /* const isInnerPoint = (ptx - 1 >= 0 && imgColorIndices.get(ptx - 1, pty) === facetColorIndex) && 74 | (pty - 1 >= 0 && imgColorIndices.get(ptx, pty - 1) === facetColorIndex) && 75 | (ptx + 1 < facetResult.width && imgColorIndices.get(ptx + 1, pty) === facetColorIndex) && 76 | (pty + 1 < facetResult.height && imgColorIndices.get(ptx, pty + 1) === facetColorIndex); 77 | */ 78 | const isInnerPoint = imgColorIndices.matchAllAround(ptx, pty, facetColorIndex); 79 | if (!isInnerPoint) { 80 | facet.borderPoints.push(new Point(ptx, pty)); 81 | } 82 | // update bounding box of facet 83 | if (ptx > facet.bbox.maxX) { 84 | facet.bbox.maxX = ptx; 85 | } 86 | if (pty > facet.bbox.maxY) { 87 | facet.bbox.maxY = pty; 88 | } 89 | if (ptx < facet.bbox.minX) { 90 | facet.bbox.minX = ptx; 91 | } 92 | if (pty < facet.bbox.minY) { 93 | facet.bbox.minY = pty; 94 | } 95 | }); 96 | /* 97 | // using a 1D flattened stack (x*width+y), we can avoid heap allocations of Point objects, which halves the garbage collection time 98 | let stack: number[] = []; 99 | stack.push(y * facetResult.width + x); 100 | 101 | while (stack.length > 0) { 102 | let pt = stack.pop()!; 103 | let ptx = pt % facetResult.width; 104 | let pty = Math.floor(pt / facetResult.width); 105 | 106 | // if the point wasn't visited before and matches 107 | // the same color 108 | if (!visited.get(ptx, pty) && 109 | imgColorIndices.get(ptx, pty) == facetColorIndex) { 110 | 111 | visited.set(ptx, pty, true); 112 | facetResult.facetMap.set(ptx, pty, facetIndex); 113 | facet.pointCount++; 114 | 115 | // determine if the point is a border or not 116 | let isInnerPoint = (ptx - 1 >= 0 && imgColorIndices.get(ptx - 1, pty) == facetColorIndex) && 117 | (pty - 1 >= 0 && imgColorIndices.get(ptx, pty - 1) == facetColorIndex) && 118 | (ptx + 1 < facetResult.width && imgColorIndices.get(ptx + 1, pty) == facetColorIndex) && 119 | (pty + 1 < facetResult.height && imgColorIndices.get(ptx, pty + 1) == facetColorIndex); 120 | 121 | if (!isInnerPoint) 122 | facet.borderPoints.push(new Point(ptx, pty)); 123 | 124 | // update bounding box of facet 125 | if (ptx > facet.bbox.maxX) facet.bbox.maxX = ptx; 126 | if (pty > facet.bbox.maxY) facet.bbox.maxY = pty; 127 | if (ptx < facet.bbox.minX) facet.bbox.minX = ptx; 128 | if (pty < facet.bbox.minY) facet.bbox.minY = pty; 129 | 130 | // visit direct adjacent points 131 | if (ptx - 1 >= 0 && !visited.get(ptx - 1, pty)) 132 | stack.push(pty * facetResult.width + (ptx - 1)); //stack.push(new Point(pt.x - 1, pt.y)); 133 | if (pty - 1 >= 0 && !visited.get(ptx, pty - 1)) 134 | stack.push((pty - 1) * facetResult.width + ptx); //stack.push(new Point(pt.x, pt.y - 1)); 135 | if (ptx + 1 < facetResult.width && !visited.get(ptx + 1, pty)) 136 | stack.push(pty * facetResult.width + (ptx + 1));//stack.push(new Point(pt.x + 1, pt.y)); 137 | if (pty + 1 < facetResult.height && !visited.get(ptx, pty + 1)) 138 | stack.push((pty + 1) * facetResult.width + ptx); //stack.push(new Point(pt.x, pt.y + 1)); 139 | } 140 | } 141 | */ 142 | return facet; 143 | } 144 | 145 | /** 146 | * Check which neighbour facets the given facet has by checking the neighbour facets at each border point 147 | */ 148 | public static buildFacetNeighbour(facet: Facet, facetResult: FacetResult) { 149 | facet.neighbourFacets = []; 150 | const uniqueFacets: IMap = {}; // poor man's set 151 | for (const pt of facet.borderPoints) { 152 | if (pt.x - 1 >= 0) { 153 | const leftFacetId = facetResult.facetMap.get(pt.x - 1, pt.y); 154 | if (leftFacetId !== facet.id) { 155 | uniqueFacets[leftFacetId] = true; 156 | } 157 | } 158 | if (pt.y - 1 >= 0) { 159 | const topFacetId = facetResult.facetMap.get(pt.x, pt.y - 1); 160 | if (topFacetId !== facet.id) { 161 | uniqueFacets[topFacetId] = true; 162 | } 163 | } 164 | if (pt.x + 1 < facetResult.width) { 165 | const rightFacetId = facetResult.facetMap.get(pt.x + 1, pt.y); 166 | if (rightFacetId !== facet.id) { 167 | uniqueFacets[rightFacetId] = true; 168 | } 169 | } 170 | if (pt.y + 1 < facetResult.height) { 171 | const bottomFacetId = facetResult.facetMap.get(pt.x, pt.y + 1); 172 | if (bottomFacetId !== facet.id) { 173 | uniqueFacets[bottomFacetId] = true; 174 | } 175 | } 176 | } 177 | for (const k of Object.keys(uniqueFacets)) { 178 | if (uniqueFacets.hasOwnProperty(k)) { 179 | facet.neighbourFacets.push(parseInt(k)); 180 | } 181 | } 182 | // the neighbour array is updated so it's not dirty anymore 183 | facet.neighbourFacetsIsDirty = false; 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/lib/datastructs.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface IComparable { 3 | compareTo(other: IComparable): number; 4 | } 5 | export interface IHashable { 6 | getKey(): string; 7 | } 8 | 9 | export interface IHeapItem extends IComparable, IHashable { 10 | 11 | } 12 | 13 | export class Map { 14 | private obj: any; 15 | 16 | constructor() { 17 | this.obj = {}; 18 | } 19 | 20 | public containsKey(key: string): boolean { 21 | return key in this.obj; 22 | } 23 | 24 | public getKeys(): string[] { 25 | const keys: string[] = []; 26 | for (const el in this.obj) { 27 | if (this.obj.hasOwnProperty(el)) { 28 | keys.push(el); 29 | } 30 | } 31 | return keys; 32 | } 33 | 34 | public get(key: string): TValue | null { 35 | const o = this.obj[key]; 36 | if (typeof o === "undefined") { 37 | return null; 38 | } else { 39 | return o as TValue; 40 | } 41 | } 42 | 43 | public put(key: string, value: TValue): void { 44 | this.obj[key] = value; 45 | } 46 | 47 | public remove(key: string) { 48 | delete this.obj[key]; 49 | } 50 | 51 | public clone(): Map { 52 | const m = new Map(); 53 | m.obj = {}; 54 | for (const p in this.obj) { 55 | m.obj[p] = this.obj[p]; 56 | } 57 | return m; 58 | } 59 | } 60 | class Heap { 61 | 62 | private array: T[]; 63 | private keyMap: Map; 64 | 65 | constructor() { 66 | this.array = []; 67 | this.keyMap = new Map(); 68 | } 69 | 70 | public add(obj: T): void { 71 | if (this.keyMap.containsKey(obj.getKey())) { 72 | throw new Error("Item with key " + obj.getKey() + " already exists in the heap"); 73 | } 74 | 75 | this.array.push(obj); 76 | this.keyMap.put(obj.getKey(), this.array.length - 1); 77 | this.checkParentRequirement(this.array.length - 1); 78 | } 79 | 80 | public replaceAt(idx: number, newobj: T): void { 81 | this.array[idx] = newobj; 82 | this.keyMap.put(newobj.getKey(), idx); 83 | this.checkParentRequirement(idx); 84 | this.checkChildrenRequirement(idx); 85 | } 86 | 87 | public shift(): T { 88 | return this.removeAt(0); 89 | } 90 | 91 | public remove(obj: T): void { 92 | const idx = this.keyMap.get(obj.getKey()); 93 | 94 | if (idx === -1) { 95 | return; 96 | } 97 | this.removeAt(idx!); 98 | } 99 | 100 | public removeWhere(predicate: (el: T) => boolean) { 101 | const itemsToRemove: T[] = []; 102 | for (let i: number = this.array.length - 1; i >= 0; i--) { 103 | if (predicate(this.array[i])) { 104 | itemsToRemove.push(this.array[i]); 105 | } 106 | } 107 | for (const el of itemsToRemove) { 108 | this.remove(el); 109 | } 110 | for (const el of this.array) { 111 | if (predicate(el)) { 112 | console.log("Idx of element not removed: " + this.keyMap.get(el.getKey())); 113 | throw new Error("element not removed: " + el.getKey()); 114 | } 115 | } 116 | } 117 | 118 | private removeAt(idx: number): T { 119 | const obj: any = this.array[idx]; 120 | this.keyMap.remove(obj.getKey()); 121 | const isLastElement: boolean = idx === this.array.length - 1; 122 | if (this.array.length > 0) { 123 | const newobj: any = this.array.pop(); 124 | if (!isLastElement && this.array.length > 0) { 125 | this.replaceAt(idx, newobj); 126 | } 127 | } 128 | return obj; 129 | } 130 | 131 | public foreach(func: (el: T) => void) { 132 | const arr = this.array.sort((e, e2) => e.compareTo(e2)); 133 | for (const el of arr) { 134 | func(el); 135 | } 136 | } 137 | 138 | public peek(): T { 139 | return this.array[0]; 140 | } 141 | 142 | public contains(key: string) { 143 | return this.keyMap.containsKey(key); 144 | } 145 | 146 | public at(key: string): T | null { 147 | const obj = this.keyMap.get(key); 148 | if (typeof obj === "undefined") { 149 | return null; 150 | } else { 151 | return this.array[obj as number]; 152 | } 153 | } 154 | 155 | public size(): number { 156 | return this.array.length; 157 | } 158 | 159 | public checkHeapRequirement(item: T) { 160 | const idx = this.keyMap.get(item.getKey()) as number; 161 | if (idx != null) { 162 | this.checkParentRequirement(idx); 163 | this.checkChildrenRequirement(idx); 164 | } 165 | } 166 | 167 | private checkChildrenRequirement(idx: number): void { 168 | let stop: boolean = false; 169 | while (!stop) { 170 | const left: number = this.getLeftChildIndex(idx); 171 | let right: number = left === -1 ? -1 : left + 1; 172 | 173 | if (left === -1) { 174 | return; 175 | } 176 | if (right >= this.size()) { 177 | right = -1; 178 | } 179 | 180 | let minIdx: number; 181 | if (right === -1) { 182 | minIdx = left; 183 | } else { 184 | minIdx = (this.array[left].compareTo(this.array[right]) < 0) ? left : right; 185 | } 186 | 187 | if (this.array[idx].compareTo(this.array[minIdx]) > 0) { 188 | this.swap(idx, minIdx); 189 | idx = minIdx; // iteratively instead of recursion for this.checkChildrenRequirement(minIdx); 190 | } else { 191 | stop = true; 192 | } 193 | } 194 | } 195 | 196 | private checkParentRequirement(idx: number): void { 197 | let curIdx: number = idx; 198 | let parentIdx: number = Heap.getParentIndex(curIdx); 199 | while (parentIdx >= 0 && this.array[parentIdx].compareTo(this.array[curIdx]) > 0) { 200 | this.swap(curIdx, parentIdx); 201 | 202 | curIdx = parentIdx; 203 | parentIdx = Heap.getParentIndex(curIdx); 204 | } 205 | } 206 | 207 | public dump(): void { 208 | if (this.size() === 0) { 209 | return; 210 | } 211 | 212 | const idx = 0; 213 | const leftIdx = this.getLeftChildIndex(idx); 214 | const rightIdx = leftIdx + 1; 215 | 216 | console.log(this.array); 217 | console.log("--- keymap ---"); 218 | console.log(this.keyMap); 219 | } 220 | 221 | private swap(i: number, j: number): void { 222 | this.keyMap.put(this.array[i].getKey(), j); 223 | this.keyMap.put(this.array[j].getKey(), i); 224 | 225 | const tmp: T = this.array[i]; 226 | this.array[i] = this.array[j]; 227 | this.array[j] = tmp; 228 | } 229 | 230 | private getLeftChildIndex(curIdx: number): number { 231 | const idx: number = ((curIdx + 1) * 2) - 1; 232 | if (idx >= this.array.length) { 233 | return -1; 234 | } else { 235 | return idx; 236 | } 237 | } 238 | 239 | private static getParentIndex(curIdx: number): number { 240 | if (curIdx === 0) { 241 | return -1; 242 | } 243 | 244 | return Math.floor((curIdx + 1) / 2) - 1; 245 | } 246 | 247 | public clone(): Heap { 248 | const h = new Heap(); 249 | h.array = this.array.slice(0); 250 | h.keyMap = this.keyMap.clone(); 251 | return h; 252 | } 253 | } 254 | 255 | export class PriorityQueue { 256 | 257 | private heap: Heap = new Heap(); 258 | 259 | public enqueue(obj: T): void { 260 | this.heap.add(obj); 261 | } 262 | 263 | public peek(): T { 264 | return this.heap.peek(); 265 | } 266 | 267 | public updatePriority(key: T) { 268 | this.heap.checkHeapRequirement(key); 269 | } 270 | 271 | public get(key: string): T | null { 272 | return this.heap.at(key); 273 | } 274 | 275 | get size(): number { 276 | return this.heap.size(); 277 | } 278 | 279 | public dequeue(): T { 280 | return this.heap.shift(); 281 | } 282 | 283 | public dump() { 284 | this.heap.dump(); 285 | } 286 | 287 | public contains(key: string) { 288 | return this.heap.contains(key); 289 | } 290 | public removeWhere(predicate: (el: T) => boolean) { 291 | this.heap.removeWhere(predicate); 292 | } 293 | 294 | public foreach(func: (el: T) => void) { 295 | this.heap.foreach(func); 296 | } 297 | 298 | public clone(): PriorityQueue { 299 | const p = new PriorityQueue(); 300 | p.heap = this.heap.clone(); 301 | return p; 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /src/gui.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Module that provides function the GUI uses and updates the DOM accordingly 3 | */ 4 | 5 | import { CancellationToken, IMap, RGB } from "./common"; 6 | import { GUIProcessManager, ProcessResult } from "./guiprocessmanager"; 7 | import { ClusteringColorSpace, Settings } from "./settings"; 8 | 9 | declare function saveSvgAsPng(el: Node, filename: string): void; 10 | 11 | let processResult: ProcessResult | null = null; 12 | let cancellationToken: CancellationToken = new CancellationToken(); 13 | 14 | const timers: IMap = {}; 15 | export function time(name: string) { 16 | console.time(name); 17 | timers[name] = new Date(); 18 | } 19 | 20 | export function timeEnd(name: string) { 21 | console.timeEnd(name); 22 | const ms = new Date().getTime() - timers[name].getTime(); 23 | log(name + ": " + ms + "ms"); 24 | delete timers[name]; 25 | } 26 | 27 | export function log(str: string) { 28 | $("#log").append("
" + str + ""); 29 | } 30 | 31 | export function parseSettings(): Settings { 32 | const settings = new Settings(); 33 | 34 | if ($("#optColorSpaceRGB").prop("checked")) { 35 | settings.kMeansClusteringColorSpace = ClusteringColorSpace.RGB; 36 | } else if ($("#optColorSpaceHSL").prop("checked")) { 37 | settings.kMeansClusteringColorSpace = ClusteringColorSpace.HSL; 38 | } else if ($("#optColorSpaceRGB").prop("checked")) { 39 | settings.kMeansClusteringColorSpace = ClusteringColorSpace.LAB; 40 | } 41 | 42 | if ($("#optFacetRemovalLargestToSmallest").prop("checked")) { 43 | settings.removeFacetsFromLargeToSmall = true; 44 | } else { 45 | settings.removeFacetsFromLargeToSmall = false; 46 | } 47 | 48 | settings.randomSeed = parseInt($("#txtRandomSeed").val() + ""); 49 | settings.kMeansNrOfClusters = parseInt($("#txtNrOfClusters").val() + ""); 50 | settings.kMeansMinDeltaDifference = parseFloat($("#txtClusterPrecision").val() + ""); 51 | 52 | settings.removeFacetsSmallerThanNrOfPoints = parseInt($("#txtRemoveFacetsSmallerThan").val() + ""); 53 | settings.maximumNumberOfFacets = parseInt($("#txtMaximumNumberOfFacets").val() + ""); 54 | 55 | settings.nrOfTimesToHalveBorderSegments = parseInt($("#txtNrOfTimesToHalveBorderSegments").val() + ""); 56 | 57 | settings.narrowPixelStripCleanupRuns = parseInt($("#txtNarrowPixelStripCleanupRuns").val() + ""); 58 | 59 | settings.resizeImageIfTooLarge = $("#chkResizeImage").prop("checked"); 60 | settings.resizeImageWidth = parseInt($("#txtResizeWidth").val() + ""); 61 | settings.resizeImageHeight = parseInt($("#txtResizeHeight").val() + ""); 62 | 63 | const restrictedColorLines = ($("#txtKMeansColorRestrictions").val() + "").split("\n"); 64 | for (const line of restrictedColorLines) { 65 | const tline = line.trim(); 66 | if (tline.indexOf("//") === 0) { 67 | // comment, skip 68 | } else { 69 | const rgbparts = tline.split(","); 70 | if (rgbparts.length === 3) { 71 | let red = parseInt(rgbparts[0]); 72 | let green = parseInt(rgbparts[1]); 73 | let blue = parseInt(rgbparts[2]); 74 | if (red < 0) red = 0; 75 | if (red > 255) red = 255; 76 | if (green < 0) green = 0; 77 | if (green > 255) green = 255; 78 | if (blue < 0) blue = 0; 79 | if (blue > 255) blue = 255; 80 | 81 | if (!isNaN(red) && !isNaN(green) && !isNaN(blue)) { 82 | settings.kMeansColorRestrictions.push([red, green, blue]); 83 | } 84 | } 85 | } 86 | } 87 | 88 | return settings; 89 | } 90 | 91 | export async function process() { 92 | try { 93 | const settings: Settings = parseSettings(); 94 | // cancel old process & create new 95 | cancellationToken.isCancelled = true; 96 | cancellationToken = new CancellationToken(); 97 | processResult = await GUIProcessManager.process(settings, cancellationToken); 98 | await updateOutput(); 99 | const tabsOutput = M.Tabs.getInstance(document.getElementById("tabsOutput")!); 100 | tabsOutput.select("output-pane"); 101 | } catch (e) { 102 | log("Error: " + e.message + " at " + e.stack); 103 | } 104 | } 105 | 106 | export async function updateOutput() { 107 | 108 | if (processResult != null) { 109 | const showLabels = $("#chkShowLabels").prop("checked"); 110 | const fill = $("#chkFillFacets").prop("checked"); 111 | const stroke = $("#chkShowBorders").prop("checked"); 112 | 113 | const sizeMultiplier = parseInt($("#txtSizeMultiplier").val() + ""); 114 | const fontSize = parseInt($("#txtLabelFontSize").val() + ""); 115 | 116 | const fontColor = $("#txtLabelFontColor").val() + ""; 117 | 118 | $("#statusSVGGenerate").css("width", "0%"); 119 | 120 | $(".status.SVGGenerate").removeClass("complete"); 121 | $(".status.SVGGenerate").addClass("active"); 122 | 123 | const svg = await GUIProcessManager.createSVG(processResult.facetResult, processResult.colorsByIndex, sizeMultiplier, fill, stroke, showLabels, fontSize, fontColor, (progress) => { 124 | if (cancellationToken.isCancelled) { throw new Error("Cancelled"); } 125 | $("#statusSVGGenerate").css("width", Math.round(progress * 100) + "%"); 126 | }); 127 | $("#svgContainer").empty().append(svg); 128 | $("#palette").empty().append(createPaletteHtml(processResult.colorsByIndex)); 129 | ($("#palette .color") as any).tooltip(); 130 | $(".status").removeClass("active"); 131 | $(".status.SVGGenerate").addClass("complete"); 132 | } 133 | } 134 | 135 | function createPaletteHtml(colorsByIndex: RGB[]) { 136 | let html = ""; 137 | for (let c: number = 0; c < colorsByIndex.length; c++) { 138 | const style = "background-color: " + `rgb(${colorsByIndex[c][0]},${colorsByIndex[c][1]},${colorsByIndex[c][2]})`; 139 | html += `
${c}
`; 140 | } 141 | return $(html); 142 | } 143 | 144 | export function downloadPalettePng() { 145 | if (processResult == null) { return; } 146 | const colorsByIndex: RGB[] = processResult.colorsByIndex; 147 | 148 | const canvas = document.createElement("canvas"); 149 | 150 | const nrOfItemsPerRow = 10; 151 | const nrRows = Math.ceil(colorsByIndex.length / nrOfItemsPerRow); 152 | const margin = 10; 153 | const cellWidth = 80; 154 | const cellHeight = 70; 155 | 156 | canvas.width = margin + nrOfItemsPerRow * (cellWidth + margin); 157 | canvas.height = margin + nrRows * (cellHeight + margin); 158 | const ctx = canvas.getContext("2d")!; 159 | ctx.translate(0.5, 0.5); 160 | 161 | ctx.fillStyle = "white"; 162 | ctx.fillRect(0, 0, canvas.width, canvas.height); 163 | for (let i = 0; i < colorsByIndex.length; i++) { 164 | const color = colorsByIndex[i]; 165 | 166 | const x = margin + (i % nrOfItemsPerRow) * (cellWidth + margin); 167 | const y = margin + Math.floor(i / nrOfItemsPerRow) * (cellHeight + margin); 168 | 169 | ctx.fillStyle = `rgb(${color[0]}, ${color[1]}, ${color[2]})`; 170 | ctx.fillRect(x, y, cellWidth, cellHeight - 20); 171 | ctx.strokeStyle = "#888"; 172 | ctx.strokeRect(x, y, cellWidth, cellHeight - 20); 173 | 174 | const nrText = i + ""; 175 | ctx.fillStyle = "black"; 176 | ctx.strokeStyle = "#CCC"; 177 | ctx.font = "20px Tahoma"; 178 | const nrTextSize = ctx.measureText(nrText); 179 | ctx.lineWidth = 2; 180 | ctx.strokeText(nrText, x + cellWidth / 2 - nrTextSize.width / 2, y + cellHeight / 2 - 5); 181 | ctx.fillText(nrText, x + cellWidth / 2 - nrTextSize.width / 2, y + cellHeight / 2 - 5); 182 | ctx.lineWidth = 1; 183 | 184 | ctx.font = "10px Tahoma"; 185 | const rgbText = "RGB: " + Math.floor(color[0]) + "," + Math.floor(color[1]) + "," + Math.floor(color[2]); 186 | const rgbTextSize = ctx.measureText(rgbText); 187 | ctx.fillStyle = "black"; 188 | ctx.fillText(rgbText, x + cellWidth / 2 - rgbTextSize.width / 2, y + cellHeight - 10); 189 | } 190 | 191 | const dataURL = canvas.toDataURL("image/png"); 192 | const dl = document.createElement("a"); 193 | document.body.appendChild(dl); 194 | dl.setAttribute("href", dataURL); 195 | dl.setAttribute("download", "palette.png"); 196 | dl.click(); 197 | } 198 | 199 | export function downloadPNG() { 200 | if ($("#svgContainer svg").length > 0) { 201 | saveSvgAsPng($("#svgContainer svg").get(0), "paintbynumbers.png"); 202 | } 203 | } 204 | 205 | export function downloadSVG() { 206 | if ($("#svgContainer svg").length > 0) { 207 | const svgEl = $("#svgContainer svg").get(0) as any; 208 | 209 | svgEl.setAttribute("xmlns", "http://www.w3.org/2000/svg"); 210 | const svgData = svgEl.outerHTML; 211 | const preface = '\r\n'; 212 | const svgBlob = new Blob([preface, svgData], { type: "image/svg+xml;charset=utf-8" }); 213 | const svgUrl = URL.createObjectURL(svgBlob); 214 | const downloadLink = document.createElement("a"); 215 | downloadLink.href = svgUrl; 216 | downloadLink.download = "paintbynumbers.svg"; 217 | document.body.appendChild(downloadLink); 218 | downloadLink.click(); 219 | document.body.removeChild(downloadLink); 220 | 221 | /* 222 | var svgAsXML = (new XMLSerializer).serializeToString($("#svgContainer svg").get(0)); 223 | let dataURL = "data:image/svg+xml," + encodeURIComponent(svgAsXML); 224 | var dl = document.createElement("a"); 225 | document.body.appendChild(dl); 226 | dl.setAttribute("href", dataURL); 227 | dl.setAttribute("download", "paintbynumbers.svg"); 228 | dl.click(); 229 | */ 230 | } 231 | } 232 | 233 | export function loadExample(imgId: string) { 234 | // load image 235 | const img = document.getElementById(imgId) as HTMLImageElement; 236 | const c = document.getElementById("canvas") as HTMLCanvasElement; 237 | const ctx = c.getContext("2d")!; 238 | c.width = img.naturalWidth; 239 | c.height = img.naturalHeight; 240 | ctx.drawImage(img, 0, 0); 241 | } 242 | -------------------------------------------------------------------------------- /src-cli/main.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"main.js","sourceRoot":"","sources":["main.ts"],"names":[],"mappings":"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"} -------------------------------------------------------------------------------- /src/colorreductionmanagement.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Color reduction management of the process: clustering to reduce colors & creating color map 3 | */ 4 | import { delay, IMap, RGB } from "./common"; 5 | import { KMeans, Vector } from "./lib/clustering"; 6 | import { hslToRgb, lab2rgb, rgb2lab, rgbToHsl } from "./lib/colorconversion"; 7 | import { ClusteringColorSpace, Settings } from "./settings"; 8 | import { Uint8Array2D } from "./structs/typedarrays"; 9 | import { Random } from "./random"; 10 | 11 | export class ColorMapResult { 12 | public imgColorIndices!: Uint8Array2D; 13 | public colorsByIndex!: RGB[]; 14 | public width!: number; 15 | public height!: number; 16 | } 17 | 18 | export class ColorReducer { 19 | 20 | /** 21 | * Creates a map of the various colors used 22 | */ 23 | public static createColorMap(kmeansImgData: ImageData) { 24 | const imgColorIndices = new Uint8Array2D(kmeansImgData.width, kmeansImgData.height); 25 | let colorIndex = 0; 26 | const colors: IMap = {}; 27 | const colorsByIndex: RGB[] = []; 28 | 29 | let idx = 0; 30 | for (let j: number = 0; j < kmeansImgData.height; j++) { 31 | for (let i: number = 0; i < kmeansImgData.width; i++) { 32 | const r = kmeansImgData.data[idx++]; 33 | const g = kmeansImgData.data[idx++]; 34 | const b = kmeansImgData.data[idx++]; 35 | const a = kmeansImgData.data[idx++]; 36 | let currentColorIndex; 37 | const color = r + "," + g + "," + b; 38 | if (typeof colors[color] === "undefined") { 39 | currentColorIndex = colorIndex; 40 | colors[color] = colorIndex; 41 | colorsByIndex.push([r, g, b]); 42 | colorIndex++; 43 | } else { 44 | currentColorIndex = colors[color]; 45 | } 46 | imgColorIndices.set(i, j, currentColorIndex); 47 | } 48 | } 49 | 50 | const result = new ColorMapResult(); 51 | result.imgColorIndices = imgColorIndices; 52 | result.colorsByIndex = colorsByIndex; 53 | result.width = kmeansImgData.width; 54 | result.height = kmeansImgData.height; 55 | 56 | return result; 57 | } 58 | 59 | /** 60 | * Applies K-means clustering on the imgData to reduce the colors to 61 | * k clusters and then output the result to the given outputImgData 62 | */ 63 | public static async applyKMeansClustering(imgData: ImageData, outputImgData: ImageData, ctx: CanvasRenderingContext2D, settings: Settings, onUpdate: ((kmeans: KMeans) => void) | null = null) { 64 | const vectors: Vector[] = []; 65 | let idx = 0; 66 | let vIdx = 0; 67 | 68 | const bitsToChopOff = 2; // r,g,b gets rounded to every 4 values, 0,4,8,... 69 | 70 | // group by color, add points as 1D index to prevent Point object allocation 71 | const pointsByColor: IMap = {}; 72 | for (let j: number = 0; j < imgData.height; j++) { 73 | for (let i: number = 0; i < imgData.width; i++) { 74 | let r = imgData.data[idx++]; 75 | let g = imgData.data[idx++]; 76 | let b = imgData.data[idx++]; 77 | const a = imgData.data[idx++]; 78 | 79 | // small performance boost: reduce bitness of colors by chopping off the last bits 80 | // this will group more colors with only slight variation in color together, reducing the size of the points 81 | 82 | r = r >> bitsToChopOff << bitsToChopOff; 83 | g = g >> bitsToChopOff << bitsToChopOff; 84 | b = b >> bitsToChopOff << bitsToChopOff; 85 | 86 | const color = `${r},${g},${b}`; 87 | if (!(color in pointsByColor)) { 88 | pointsByColor[color] = [j * imgData.width + i]; 89 | } else { 90 | pointsByColor[color].push(j * imgData.width + i); 91 | } 92 | } 93 | } 94 | 95 | for (const color of Object.keys(pointsByColor)) { 96 | const rgb: number[] = color.split(",").map((v) => parseInt(v)); 97 | 98 | // determine vector data based on color space conversion 99 | let data: number[]; 100 | if (settings.kMeansClusteringColorSpace === ClusteringColorSpace.RGB) { 101 | data = rgb; 102 | } else if (settings.kMeansClusteringColorSpace === ClusteringColorSpace.HSL) { 103 | data = rgbToHsl(rgb[0], rgb[1], rgb[2]); 104 | } else if (settings.kMeansClusteringColorSpace === ClusteringColorSpace.LAB) { 105 | data = rgb2lab(rgb); 106 | } else { 107 | data = rgb; 108 | } 109 | // determine the weight (#pointsOfColor / #totalpoints) of each color 110 | const weight = pointsByColor[color].length / (imgData.width * imgData.height); 111 | 112 | const vec = new Vector(data, weight); 113 | vec.tag = rgb; 114 | vectors[vIdx++] = vec; 115 | } 116 | 117 | const random = new Random(settings.randomSeed === 0 ? new Date().getTime() : settings.randomSeed); 118 | // vectors of all the unique colors are built, time to cluster them 119 | const kmeans = new KMeans(vectors, settings.kMeansNrOfClusters, random); 120 | 121 | let curTime = new Date().getTime(); 122 | 123 | kmeans.step(); 124 | while (kmeans.currentDeltaDistanceDifference > settings.kMeansMinDeltaDifference) { 125 | kmeans.step(); 126 | 127 | // update GUI every 500ms 128 | if (new Date().getTime() - curTime > 500) { 129 | curTime = new Date().getTime(); 130 | 131 | await delay(0); 132 | if (onUpdate != null) { 133 | ColorReducer.updateKmeansOutputImageData(kmeans, settings, pointsByColor, imgData, outputImgData, false); 134 | onUpdate(kmeans); 135 | } 136 | } 137 | 138 | } 139 | 140 | // update the output image data (because it will be used for further processing) 141 | ColorReducer.updateKmeansOutputImageData(kmeans, settings, pointsByColor, imgData, outputImgData, true); 142 | 143 | if (onUpdate != null) { 144 | onUpdate(kmeans); 145 | } 146 | } 147 | 148 | /** 149 | * Updates the image data from the current kmeans centroids and their respective associated colors (vectors) 150 | */ 151 | public static updateKmeansOutputImageData(kmeans: KMeans, settings: Settings, pointsByColor: IMap, imgData: ImageData, outputImgData: ImageData, restrictToSpecifiedColors: boolean) { 152 | 153 | for (let c: number = 0; c < kmeans.centroids.length; c++) { 154 | // for each cluster centroid 155 | const centroid = kmeans.centroids[c]; 156 | 157 | // points per category are the different unique colors belonging to that cluster 158 | for (const v of kmeans.pointsPerCategory[c]) { 159 | 160 | // determine the rgb color value of the cluster centroid 161 | let rgb: number[]; 162 | if (settings.kMeansClusteringColorSpace === ClusteringColorSpace.RGB) { 163 | rgb = centroid.values; 164 | } else if (settings.kMeansClusteringColorSpace === ClusteringColorSpace.HSL) { 165 | const hsl = centroid.values; 166 | rgb = hslToRgb(hsl[0], hsl[1], hsl[2]); 167 | } else if (settings.kMeansClusteringColorSpace === ClusteringColorSpace.LAB) { 168 | const lab = centroid.values; 169 | rgb = lab2rgb(lab); 170 | } else { 171 | rgb = centroid.values; 172 | } 173 | 174 | // remove decimals 175 | rgb = rgb.map(v => Math.floor(v)); 176 | 177 | if (restrictToSpecifiedColors) { 178 | if (settings.kMeansColorRestrictions.length > 0) { 179 | // there are color restrictions, for each centroid find the color from the color restrictions that's the closest 180 | let minDistance = Number.MAX_VALUE; 181 | let closestRestrictedColor: RGB | string | null = null; 182 | for (const color of settings.kMeansColorRestrictions) { 183 | // RGB distance is not very good for the human eye perception, convert both to lab and then calculate the distance 184 | const centroidLab = rgb2lab(rgb); 185 | 186 | let restrictionLab: number[]; 187 | if (typeof color === "string") { 188 | restrictionLab = rgb2lab(settings.colorAliases[color]); 189 | } else { 190 | restrictionLab = rgb2lab(color); 191 | } 192 | 193 | const distance = Math.sqrt((centroidLab[0] - restrictionLab[0]) * (centroidLab[0] - restrictionLab[0]) + 194 | (centroidLab[1] - restrictionLab[1]) * (centroidLab[1] - restrictionLab[1]) + 195 | (centroidLab[2] - restrictionLab[2]) * (centroidLab[2] - restrictionLab[2])); 196 | if (distance < minDistance) { 197 | minDistance = distance; 198 | closestRestrictedColor = color; 199 | } 200 | } 201 | // use this color instead 202 | if (closestRestrictedColor !== null) { 203 | if (typeof closestRestrictedColor === "string") { 204 | rgb = settings.colorAliases[closestRestrictedColor]; 205 | } else { 206 | rgb = closestRestrictedColor; 207 | } 208 | } 209 | } 210 | } 211 | 212 | let pointRGB: number[] = v.tag; 213 | 214 | // replace all pixels of the old color by the new centroid color 215 | const pointColor = `${Math.floor(pointRGB[0])},${Math.floor(pointRGB[1])},${Math.floor(pointRGB[2])}`; 216 | for (const pt of pointsByColor[pointColor]) { 217 | const ptx = pt % imgData.width; 218 | const pty = Math.floor(pt / imgData.width); 219 | let dataOffset = (pty * imgData.width + ptx) * 4; 220 | outputImgData.data[dataOffset++] = rgb[0]; 221 | outputImgData.data[dataOffset++] = rgb[1]; 222 | outputImgData.data[dataOffset++] = rgb[2]; 223 | } 224 | } 225 | } 226 | } 227 | 228 | /** 229 | * Builds a distance matrix for each color to each other 230 | */ 231 | public static buildColorDistanceMatrix(colorsByIndex: RGB[]) { 232 | const colorDistances: number[][] = new Array(colorsByIndex.length); 233 | for (let j: number = 0; j < colorsByIndex.length; j++) { 234 | colorDistances[j] = new Array(colorDistances.length); 235 | } 236 | for (let j: number = 0; j < colorsByIndex.length; j++) { 237 | for (let i: number = j; i < colorsByIndex.length; i++) { 238 | const c1 = colorsByIndex[j]; 239 | const c2 = colorsByIndex[i]; 240 | const distance = Math.sqrt((c1[0] - c2[0]) * (c1[0] - c2[0]) + 241 | (c1[1] - c2[1]) * (c1[1] - c2[1]) + 242 | (c1[2] - c2[2]) * (c1[2] - c2[2])); 243 | colorDistances[i][j] = distance; 244 | colorDistances[j][i] = distance; 245 | } 246 | } 247 | return colorDistances; 248 | } 249 | 250 | public static async processNarrowPixelStripCleanup(colormapResult: ColorMapResult) { 251 | // build the color distance matrix, which describes the distance of each color to each other 252 | const colorDistances: number[][] = ColorReducer.buildColorDistanceMatrix(colormapResult.colorsByIndex); 253 | 254 | let count = 0; 255 | const imgColorIndices = colormapResult.imgColorIndices; 256 | for (let j: number = 1; j < colormapResult.height - 1; j++) { 257 | for (let i: number = 1; i < colormapResult.width - 1; i++) { 258 | const top = imgColorIndices.get(i, j - 1); 259 | const bottom = imgColorIndices.get(i, j + 1); 260 | const left = imgColorIndices.get(i - 1, j); 261 | const right = imgColorIndices.get(i + 1, j); 262 | const cur = imgColorIndices.get(i, j); 263 | if (cur !== top && cur !== bottom && cur !== left && cur !== right) { 264 | // single pixel 265 | } else if (cur !== top && cur !== bottom) { 266 | // check the color distance whether the top or bottom color is closer 267 | const topColorDistance = colorDistances[cur][top]; 268 | const bottomColorDistance = colorDistances[cur][bottom]; 269 | imgColorIndices.set(i, j, topColorDistance < bottomColorDistance ? top : bottom); 270 | count++; 271 | } else if (cur !== left && cur !== right) { 272 | // check the color distance whether the top or bottom color is closer 273 | const leftColorDistance = colorDistances[cur][left]; 274 | const rightColorDistance = colorDistances[cur][right]; 275 | imgColorIndices.set(i, j, leftColorDistance < rightColorDistance ? left : right); 276 | count++; 277 | } 278 | } 279 | } 280 | console.log(count + " pixels replaced to remove narrow pixel strips"); 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /src-cli/main.ts: -------------------------------------------------------------------------------- 1 | import * as canvas from "canvas"; 2 | import * as fs from "fs"; 3 | import * as minimist from "minimist"; 4 | import * as path from "path"; 5 | import * as process from "process"; 6 | import { ColorReducer } from "../src/colorreductionmanagement"; 7 | import { RGB } from "../src/common"; 8 | import { FacetBorderSegmenter } from "../src/facetBorderSegmenter"; 9 | import { FacetBorderTracer } from "../src/facetBorderTracer"; 10 | import { FacetCreator } from "../src/facetCreator"; 11 | import { FacetLabelPlacer } from "../src/facetLabelPlacer"; 12 | import { FacetResult } from "../src/facetmanagement"; 13 | import { FacetReducer } from "../src/facetReducer"; 14 | import { Settings } from "../src/settings"; 15 | import { Point } from "../src/structs/point"; 16 | const svg2img = require("svg2img"); 17 | 18 | class CLISettingsOutputProfile { 19 | public name: string = ""; 20 | public svgShowLabels: boolean = true; 21 | public svgFillFacets: boolean = true; 22 | public svgShowBorders: boolean = true; 23 | public svgSizeMultiplier: number = 3; 24 | 25 | public svgFontSize: number = 60; 26 | public svgFontColor: string = "black"; 27 | 28 | public filetype: "svg" | "png" | "jpg" = "svg"; 29 | public filetypeQuality: number = 95; 30 | } 31 | 32 | class CLISettings extends Settings { 33 | 34 | public outputProfiles: CLISettingsOutputProfile[] = []; 35 | 36 | } 37 | 38 | async function main() { 39 | const args = minimist(process.argv.slice(2)); 40 | const imagePath = args.i; 41 | const svgPath = args.o; 42 | 43 | if (typeof imagePath === "undefined" || typeof svgPath === "undefined") { 44 | console.log("Usage: exe -i -o [-c ]"); 45 | process.exit(1); 46 | } 47 | 48 | let configPath = args.c; 49 | if (typeof configPath === "undefined") { 50 | configPath = path.join(process.cwd(), "settings.json"); 51 | } else { 52 | if (!path.isAbsolute(configPath)) { 53 | configPath = path.join(process.cwd(), configPath); 54 | } 55 | } 56 | 57 | const settings: CLISettings = require(configPath); 58 | 59 | const img = await canvas.loadImage(imagePath); 60 | const c = canvas.createCanvas(img.width, img.height); 61 | const ctx = c.getContext("2d"); 62 | ctx.drawImage(img, 0, 0, c.width, c.height); 63 | let imgData = ctx.getImageData(0, 0, c.width, c.height); 64 | 65 | // resize if required 66 | if (settings.resizeImageIfTooLarge && (c.width > settings.resizeImageWidth || c.height > settings.resizeImageHeight)) { 67 | let width = c.width; 68 | let height = c.height; 69 | if (width > settings.resizeImageWidth) { 70 | const newWidth = settings.resizeImageWidth; 71 | const newHeight = c.height / c.width * settings.resizeImageWidth; 72 | width = newWidth; 73 | height = newHeight; 74 | } 75 | if (height > settings.resizeImageHeight) { 76 | const newHeight = settings.resizeImageHeight; 77 | const newWidth = width / height * newHeight; 78 | width = newWidth; 79 | height = newHeight; 80 | } 81 | 82 | const tempCanvas = canvas.createCanvas(width, height); 83 | tempCanvas.width = width; 84 | tempCanvas.height = height; 85 | tempCanvas.getContext("2d")!.drawImage(c, 0, 0, width, height); 86 | c.width = width; 87 | c.height = height; 88 | ctx.drawImage(tempCanvas, 0, 0, width, height); 89 | imgData = ctx.getImageData(0, 0, c.width, c.height); 90 | 91 | console.log(`Resized image to ${width}x${height}`); 92 | } 93 | 94 | console.log("Running k-means clustering"); 95 | const cKmeans = canvas.createCanvas(imgData.width, imgData.height); 96 | const ctxKmeans = cKmeans.getContext("2d")!; 97 | ctxKmeans.fillStyle = "white"; 98 | ctxKmeans.fillRect(0, 0, cKmeans.width, cKmeans.height); 99 | 100 | const kmeansImgData = ctxKmeans.getImageData(0, 0, cKmeans.width, cKmeans.height); 101 | await ColorReducer.applyKMeansClustering(imgData, kmeansImgData, ctx, settings, (kmeans) => { 102 | const progress = (100 - (kmeans.currentDeltaDistanceDifference > 100 ? 100 : kmeans.currentDeltaDistanceDifference)) / 100; 103 | ctxKmeans.putImageData(kmeansImgData, 0, 0); 104 | }); 105 | 106 | const colormapResult = ColorReducer.createColorMap(kmeansImgData); 107 | 108 | let facetResult = new FacetResult(); 109 | if (typeof settings.narrowPixelStripCleanupRuns === "undefined" || settings.narrowPixelStripCleanupRuns === 0) { 110 | console.log("Creating facets"); 111 | facetResult = await FacetCreator.getFacets(imgData.width, imgData.height, colormapResult.imgColorIndices, (progress) => { 112 | // progress 113 | }); 114 | 115 | console.log("Reducing facets"); 116 | await FacetReducer.reduceFacets(settings.removeFacetsSmallerThanNrOfPoints, settings.removeFacetsFromLargeToSmall, settings.maximumNumberOfFacets, colormapResult.colorsByIndex, facetResult, colormapResult.imgColorIndices, (progress) => { 117 | // progress 118 | }); 119 | } else { 120 | for (let run = 0; run < settings.narrowPixelStripCleanupRuns; run++) { 121 | console.log("Removing narrow pixels run #" + (run + 1)); 122 | // clean up narrow pixel strips 123 | await ColorReducer.processNarrowPixelStripCleanup(colormapResult); 124 | 125 | console.log("Creating facets"); 126 | facetResult = await FacetCreator.getFacets(imgData.width, imgData.height, colormapResult.imgColorIndices, (progress) => { 127 | // progress 128 | }); 129 | 130 | console.log("Reducing facets"); 131 | await FacetReducer.reduceFacets(settings.removeFacetsSmallerThanNrOfPoints, settings.removeFacetsFromLargeToSmall, settings.maximumNumberOfFacets, colormapResult.colorsByIndex, facetResult, colormapResult.imgColorIndices, (progress) => { 132 | // progress 133 | }); 134 | 135 | // the colormapResult.imgColorIndices get updated as the facets are reduced, so just do a few runs of pixel cleanup 136 | } 137 | } 138 | 139 | console.log("Build border paths"); 140 | await FacetBorderTracer.buildFacetBorderPaths(facetResult, (progress) => { 141 | // progress 142 | }); 143 | 144 | console.log("Build border path segments"); 145 | await FacetBorderSegmenter.buildFacetBorderSegments(facetResult, settings.nrOfTimesToHalveBorderSegments, (progress) => { 146 | // progress 147 | }); 148 | 149 | console.log("Determine label placement"); 150 | await FacetLabelPlacer.buildFacetLabelBounds(facetResult, (progress) => { 151 | // progress 152 | }); 153 | 154 | for (const profile of settings.outputProfiles) { 155 | console.log("Generating output for " + profile.name); 156 | 157 | if (typeof profile.filetype === "undefined") { 158 | profile.filetype = "svg"; 159 | } 160 | 161 | const svgProfilePath = path.join(path.dirname(svgPath), path.basename(svgPath).substr(0, path.basename(svgPath).length - path.extname(svgPath).length) + "-" + profile.name) + "." + profile.filetype; 162 | const svgString = await createSVG(facetResult, colormapResult.colorsByIndex, profile.svgSizeMultiplier, profile.svgFillFacets, profile.svgShowBorders, profile.svgShowLabels, profile.svgFontSize, profile.svgFontColor); 163 | 164 | if (profile.filetype === "svg") { 165 | fs.writeFileSync(svgProfilePath, svgString); 166 | } else if (profile.filetype === "png") { 167 | 168 | const imageBuffer = await new Promise((then, reject) => { 169 | svg2img(svgString, function (error: Error, buffer: Buffer) { 170 | if (error) { 171 | reject(error); 172 | } else { 173 | then(buffer); 174 | } 175 | }); 176 | }); 177 | fs.writeFileSync(svgProfilePath, imageBuffer); 178 | } else if (profile.filetype === "jpg") { 179 | const imageBuffer = await new Promise((then, reject) => { 180 | svg2img(svgString, { format: "jpg", quality: profile.filetypeQuality }, function (error: Error, buffer: Buffer) { 181 | if (error) { 182 | reject(error); 183 | } else { 184 | then(buffer); 185 | } 186 | }); 187 | }); 188 | fs.writeFileSync(svgProfilePath, imageBuffer); 189 | } 190 | } 191 | 192 | console.log("Generating palette info"); 193 | const palettePath = path.join(path.dirname(svgPath), path.basename(svgPath).substr(0, path.basename(svgPath).length - path.extname(svgPath).length) + ".json"); 194 | 195 | const colorFrequency: number[] = []; 196 | for (const color of colormapResult.colorsByIndex) { 197 | colorFrequency.push(0); 198 | } 199 | 200 | for (const facet of facetResult.facets) { 201 | if (facet !== null) { 202 | colorFrequency[facet.color] += facet.pointCount; 203 | } 204 | } 205 | 206 | const colorAliasesByColor: { [key: string]: string } = {}; 207 | for (const alias of Object.keys(settings.colorAliases)) { 208 | colorAliasesByColor[settings.colorAliases[alias].join(",")] = alias; 209 | } 210 | 211 | const totalFrequency = colorFrequency.reduce((sum, val) => sum + val); 212 | 213 | const paletteInfo = JSON.stringify(colormapResult.colorsByIndex.map((color, index) => { 214 | return { 215 | areaPercentage: colorFrequency[index] / totalFrequency, 216 | color, 217 | colorAlias: colorAliasesByColor[color.join(",")], 218 | frequency: colorFrequency[index], 219 | index, 220 | }; 221 | }), null, 2); 222 | 223 | fs.writeFileSync(palettePath, paletteInfo); 224 | } 225 | 226 | async function createSVG(facetResult: FacetResult, colorsByIndex: RGB[], sizeMultiplier: number, fill: boolean, stroke: boolean, addColorLabels: boolean, fontSize: number = 60, fontColor: string = "black", onUpdate: ((progress: number) => void) | null = null) { 227 | 228 | let svgString = ""; 229 | const xmlns = "http://www.w3.org/2000/svg"; 230 | 231 | const svgWidth = sizeMultiplier * facetResult.width; 232 | const svgHeight = sizeMultiplier * facetResult.height; 233 | svgString += ` 234 | `; 235 | 236 | for (const f of facetResult.facets) { 237 | 238 | if (f != null && f.borderSegments.length > 0) { 239 | let newpath: Point[] = []; 240 | const useSegments = true; 241 | if (useSegments) { 242 | newpath = f.getFullPathFromBorderSegments(false); 243 | } else { 244 | for (let i: number = 0; i < f.borderPath.length; i++) { 245 | newpath.push(new Point(f.borderPath[i].getWallX() + 0.5, f.borderPath[i].getWallY() + 0.5)); 246 | } 247 | } 248 | if (newpath[0].x !== newpath[newpath.length - 1].x || newpath[0].y !== newpath[newpath.length - 1].y) { 249 | newpath.push(newpath[0]); 250 | } // close loop if necessary 251 | 252 | // Create a path in SVG's namespace 253 | // using quadratic curve absolute positions 254 | 255 | let svgPathString = ""; 256 | 257 | let data = "M "; 258 | data += newpath[0].x * sizeMultiplier + " " + newpath[0].y * sizeMultiplier + " "; 259 | for (let i: number = 1; i < newpath.length; i++) { 260 | const midpointX = (newpath[i].x + newpath[i - 1].x) / 2; 261 | const midpointY = (newpath[i].y + newpath[i - 1].y) / 2; 262 | data += "Q " + (midpointX * sizeMultiplier) + " " + (midpointY * sizeMultiplier) + " " + (newpath[i].x * sizeMultiplier) + " " + (newpath[i].y * sizeMultiplier) + " "; 263 | } 264 | 265 | let svgStroke = ""; 266 | if (stroke) { 267 | svgStroke = "#000"; 268 | } else { 269 | // make the border the same color as the fill color if there is no border stroke 270 | // to not have gaps in between facets 271 | if (fill) { 272 | svgStroke = `rgb(${colorsByIndex[f.color][0]},${colorsByIndex[f.color][1]},${colorsByIndex[f.color][2]})`; 273 | } 274 | } 275 | 276 | let svgFill = ""; 277 | if (fill) { 278 | svgFill = `rgb(${colorsByIndex[f.color][0]},${colorsByIndex[f.color][1]},${colorsByIndex[f.color][2]})`; 279 | } else { 280 | svgFill = "none"; 281 | } 282 | 283 | svgPathString = ``; 293 | 294 | svgPathString += ``; 295 | 296 | svgString += svgPathString; 297 | 298 | // add the color labels if necessary. I mean, this is the whole idea behind the paint by numbers part 299 | // so I don't know why you would hide them 300 | if (addColorLabels) { 301 | 302 | const labelOffsetX = f.labelBounds.minX * sizeMultiplier; 303 | const labelOffsetY = f.labelBounds.minY * sizeMultiplier; 304 | const labelWidth = f.labelBounds.width * sizeMultiplier; 305 | const labelHeight = f.labelBounds.height * sizeMultiplier; 306 | 307 | // const svgLabelString = ` 308 | // 309 | // 310 | // ${f.color} 311 | // 312 | // `; 313 | 314 | const nrOfDigits = (f.color + "").length; 315 | const svgLabelString = ` 316 | 317 | ${f.color} 318 | 319 | `; 320 | 321 | svgString += svgLabelString; 322 | } 323 | } 324 | } 325 | 326 | svgString += ``; 327 | 328 | return svgString; 329 | } 330 | 331 | main().then(() => { 332 | console.log("Finished"); 333 | }).catch((err) => { 334 | console.error("Error: " + err.name + " " + err.message + " " + err.stack); 335 | }); 336 | -------------------------------------------------------------------------------- /scripts/lib/require.js: -------------------------------------------------------------------------------- 1 | /** vim: et:ts=4:sw=4:sts=4 2 | * @license RequireJS 2.3.5 Copyright jQuery Foundation and other contributors. 3 | * Released under MIT license, https://github.com/requirejs/requirejs/blob/master/LICENSE 4 | */ 5 | var requirejs,require,define;!function(global,setTimeout){function commentReplace(e,t){return t||""}function isFunction(e){return"[object Function]"===ostring.call(e)}function isArray(e){return"[object Array]"===ostring.call(e)}function each(e,t){if(e){var i;for(i=0;i-1&&(!e[i]||!t(e[i],i,e));i-=1);}}function hasProp(e,t){return hasOwn.call(e,t)}function getOwn(e,t){return hasProp(e,t)&&e[t]}function eachProp(e,t){var i;for(i in e)if(hasProp(e,i)&&t(e[i],i))break}function mixin(e,t,i,r){return t&&eachProp(t,function(t,n){!i&&hasProp(e,n)||(!r||"object"!=typeof t||!t||isArray(t)||isFunction(t)||t instanceof RegExp?e[n]=t:(e[n]||(e[n]={}),mixin(e[n],t,i,r)))}),e}function bind(e,t){return function(){return t.apply(e,arguments)}}function scripts(){return document.getElementsByTagName("script")}function defaultOnError(e){throw e}function getGlobal(e){if(!e)return e;var t=global;return each(e.split("."),function(e){t=t[e]}),t}function makeError(e,t,i,r){var n=new Error(t+"\nhttp://requirejs.org/docs/errors.html#"+e);return n.requireType=e,n.requireModules=r,i&&(n.originalError=i),n}function newContext(e){function t(e){var t,i;for(t=0;t0&&(e.splice(t-1,2),t-=2)}}function i(e,i,r){var n,o,a,s,u,c,d,p,f,l,h=i&&i.split("/"),m=y.map,g=m&&m["*"];if(e&&(c=(e=e.split("/")).length-1,y.nodeIdCompat&&jsSuffixRegExp.test(e[c])&&(e[c]=e[c].replace(jsSuffixRegExp,"")),"."===e[0].charAt(0)&&h&&(e=h.slice(0,h.length-1).concat(e)),t(e),e=e.join("/")),r&&m&&(h||g)){e:for(a=(o=e.split("/")).length;a>0;a-=1){if(u=o.slice(0,a).join("/"),h)for(s=h.length;s>0;s-=1)if((n=getOwn(m,h.slice(0,s).join("/")))&&(n=getOwn(n,u))){d=n,p=a;break e}!f&&g&&getOwn(g,u)&&(f=getOwn(g,u),l=a)}!d&&f&&(d=f,p=l),d&&(o.splice(0,p,d),e=o.join("/"))}return getOwn(y.pkgs,e)||e}function r(e){isBrowser&&each(scripts(),function(t){if(t.getAttribute("data-requiremodule")===e&&t.getAttribute("data-requirecontext")===q.contextName)return t.parentNode.removeChild(t),!0})}function n(e){var t=getOwn(y.paths,e);if(t&&isArray(t)&&t.length>1)return t.shift(),q.require.undef(e),q.makeRequire(null,{skipMap:!0})([e]),!0}function o(e){var t,i=e?e.indexOf("!"):-1;return i>-1&&(t=e.substring(0,i),e=e.substring(i+1,e.length)),[t,e]}function a(e,t,r,n){var a,s,u,c,d=null,p=t?t.name:null,f=e,l=!0,h="";return e||(l=!1,e="_@r"+(T+=1)),c=o(e),d=c[0],e=c[1],d&&(d=i(d,p,n),s=getOwn(j,d)),e&&(d?h=r?e:s&&s.normalize?s.normalize(e,function(e){return i(e,p,n)}):-1===e.indexOf("!")?i(e,p,n):e:(d=(c=o(h=i(e,p,n)))[0],h=c[1],r=!0,a=q.nameToUrl(h))),u=!d||s||r?"":"_unnormalized"+(A+=1),{prefix:d,name:h,parentMap:t,unnormalized:!!u,url:a,originalName:f,isDefine:l,id:(d?d+"!"+h:h)+u}}function s(e){var t=e.id,i=getOwn(S,t);return i||(i=S[t]=new q.Module(e)),i}function u(e,t,i){var r=e.id,n=getOwn(S,r);!hasProp(j,r)||n&&!n.defineEmitComplete?(n=s(e)).error&&"error"===t?i(n.error):n.on(t,i):"defined"===t&&i(j[r])}function c(e,t){var i=e.requireModules,r=!1;t?t(e):(each(i,function(t){var i=getOwn(S,t);i&&(i.error=e,i.events.error&&(r=!0,i.emit("error",e)))}),r||req.onError(e))}function d(){globalDefQueue.length&&(each(globalDefQueue,function(e){var t=e[0];"string"==typeof t&&(q.defQueueMap[t]=!0),O.push(e)}),globalDefQueue=[])}function p(e){delete S[e],delete k[e]}function f(e,t,i){var r=e.map.id;e.error?e.emit("error",e.error):(t[r]=!0,each(e.depMaps,function(r,n){var o=r.id,a=getOwn(S,o);!a||e.depMatched[n]||i[o]||(getOwn(t,o)?(e.defineDep(n,j[o]),e.check()):f(a,t,i))}),i[r]=!0)}function l(){var e,t,i=1e3*y.waitSeconds,o=i&&q.startTime+i<(new Date).getTime(),a=[],s=[],u=!1,d=!0;if(!x){if(x=!0,eachProp(k,function(e){var i=e.map,c=i.id;if(e.enabled&&(i.isDefine||s.push(e),!e.error))if(!e.inited&&o)n(c)?(t=!0,u=!0):(a.push(c),r(c));else if(!e.inited&&e.fetched&&i.isDefine&&(u=!0,!i.prefix))return d=!1}),o&&a.length)return e=makeError("timeout","Load timeout for modules: "+a,null,a),e.contextName=q.contextName,c(e);d&&each(s,function(e){f(e,{},{})}),o&&!t||!u||!isBrowser&&!isWebWorker||w||(w=setTimeout(function(){w=0,l()},50)),x=!1}}function h(e){hasProp(j,e[0])||s(a(e[0],null,!0)).init(e[1],e[2])}function m(e,t,i,r){e.detachEvent&&!isOpera?r&&e.detachEvent(r,t):e.removeEventListener(i,t,!1)}function g(e){var t=e.currentTarget||e.srcElement;return m(t,q.onScriptLoad,"load","onreadystatechange"),m(t,q.onScriptError,"error"),{node:t,id:t&&t.getAttribute("data-requiremodule")}}function v(){var e;for(d();O.length;){if(null===(e=O.shift())[0])return c(makeError("mismatch","Mismatched anonymous define() module: "+e[e.length-1]));h(e)}q.defQueueMap={}}var x,b,q,E,w,y={waitSeconds:7,baseUrl:"./",paths:{},bundles:{},pkgs:{},shim:{},config:{}},S={},k={},M={},O=[],j={},P={},R={},T=1,A=1;return E={require:function(e){return e.require?e.require:e.require=q.makeRequire(e.map)},exports:function(e){if(e.usingExports=!0,e.map.isDefine)return e.exports?j[e.map.id]=e.exports:e.exports=j[e.map.id]={}},module:function(e){return e.module?e.module:e.module={id:e.map.id,uri:e.map.url,config:function(){return getOwn(y.config,e.map.id)||{}},exports:e.exports||(e.exports={})}}},b=function(e){this.events=getOwn(M,e.id)||{},this.map=e,this.shim=getOwn(y.shim,e.id),this.depExports=[],this.depMaps=[],this.depMatched=[],this.pluginMaps={},this.depCount=0},b.prototype={init:function(e,t,i,r){r=r||{},this.inited||(this.factory=t,i?this.on("error",i):this.events.error&&(i=bind(this,function(e){this.emit("error",e)})),this.depMaps=e&&e.slice(0),this.errback=i,this.inited=!0,this.ignore=r.ignore,r.enabled||this.enabled?this.enable():this.check())},defineDep:function(e,t){this.depMatched[e]||(this.depMatched[e]=!0,this.depCount-=1,this.depExports[e]=t)},fetch:function(){if(!this.fetched){this.fetched=!0,q.startTime=(new Date).getTime();var e=this.map;if(!this.shim)return e.prefix?this.callPlugin():this.load();q.makeRequire(this.map,{enableBuildCallback:!0})(this.shim.deps||[],bind(this,function(){return e.prefix?this.callPlugin():this.load()}))}},load:function(){var e=this.map.url;P[e]||(P[e]=!0,q.load(this.map.id,e))},check:function(){if(this.enabled&&!this.enabling){var e,t,i=this.map.id,r=this.depExports,n=this.exports,o=this.factory;if(this.inited){if(this.error)this.emit("error",this.error);else if(!this.defining){if(this.defining=!0,this.depCount<1&&!this.defined){if(isFunction(o)){if(this.events.error&&this.map.isDefine||req.onError!==defaultOnError)try{n=q.execCb(i,o,r,n)}catch(t){e=t}else n=q.execCb(i,o,r,n);if(this.map.isDefine&&void 0===n&&((t=this.module)?n=t.exports:this.usingExports&&(n=this.exports)),e)return e.requireMap=this.map,e.requireModules=this.map.isDefine?[this.map.id]:null,e.requireType=this.map.isDefine?"define":"require",c(this.error=e)}else n=o;if(this.exports=n,this.map.isDefine&&!this.ignore&&(j[i]=n,req.onResourceLoad)){var a=[];each(this.depMaps,function(e){a.push(e.normalizedMap||e)}),req.onResourceLoad(q,this.map,a)}p(i),this.defined=!0}this.defining=!1,this.defined&&!this.defineEmitted&&(this.defineEmitted=!0,this.emit("defined",this.exports),this.defineEmitComplete=!0)}}else hasProp(q.defQueueMap,i)||this.fetch()}},callPlugin:function(){var e=this.map,t=e.id,r=a(e.prefix);this.depMaps.push(r),u(r,"defined",bind(this,function(r){var n,o,d,f=getOwn(R,this.map.id),l=this.map.name,h=this.map.parentMap?this.map.parentMap.name:null,m=q.makeRequire(e.parentMap,{enableBuildCallback:!0});return this.map.unnormalized?(r.normalize&&(l=r.normalize(l,function(e){return i(e,h,!0)})||""),o=a(e.prefix+"!"+l,this.map.parentMap,!0),u(o,"defined",bind(this,function(e){this.map.normalizedMap=o,this.init([],function(){return e},null,{enabled:!0,ignore:!0})})),void((d=getOwn(S,o.id))&&(this.depMaps.push(o),this.events.error&&d.on("error",bind(this,function(e){this.emit("error",e)})),d.enable()))):f?(this.map.url=q.nameToUrl(f),void this.load()):((n=bind(this,function(e){this.init([],function(){return e},null,{enabled:!0})})).error=bind(this,function(e){this.inited=!0,this.error=e,e.requireModules=[t],eachProp(S,function(e){0===e.map.id.indexOf(t+"_unnormalized")&&p(e.map.id)}),c(e)}),n.fromText=bind(this,function(i,r){var o=e.name,u=a(o),d=useInteractive;r&&(i=r),d&&(useInteractive=!1),s(u),hasProp(y.config,t)&&(y.config[o]=y.config[t]);try{req.exec(i)}catch(e){return c(makeError("fromtexteval","fromText eval for "+t+" failed: "+e,e,[t]))}d&&(useInteractive=!0),this.depMaps.push(u),q.completeLoad(o),m([o],n)}),void r.load(e.name,m,n,y))})),q.enable(r,this),this.pluginMaps[r.id]=r},enable:function(){k[this.map.id]=this,this.enabled=!0,this.enabling=!0,each(this.depMaps,bind(this,function(e,t){var i,r,n;if("string"==typeof e){if(e=a(e,this.map.isDefine?this.map:this.map.parentMap,!1,!this.skipMap),this.depMaps[t]=e,n=getOwn(E,e.id))return void(this.depExports[t]=n(this));this.depCount+=1,u(e,"defined",bind(this,function(e){this.undefed||(this.defineDep(t,e),this.check())})),this.errback?u(e,"error",bind(this,this.errback)):this.events.error&&u(e,"error",bind(this,function(e){this.emit("error",e)}))}i=e.id,r=S[i],hasProp(E,i)||!r||r.enabled||q.enable(e,this)})),eachProp(this.pluginMaps,bind(this,function(e){var t=getOwn(S,e.id);t&&!t.enabled&&q.enable(e,this)})),this.enabling=!1,this.check()},on:function(e,t){var i=this.events[e];i||(i=this.events[e]=[]),i.push(t)},emit:function(e,t){each(this.events[e],function(e){e(t)}),"error"===e&&delete this.events[e]}},q={config:y,contextName:e,registry:S,defined:j,urlFetched:P,defQueue:O,defQueueMap:{},Module:b,makeModuleMap:a,nextTick:req.nextTick,onError:c,configure:function(e){if(e.baseUrl&&"/"!==e.baseUrl.charAt(e.baseUrl.length-1)&&(e.baseUrl+="/"),"string"==typeof e.urlArgs){var t=e.urlArgs;e.urlArgs=function(e,i){return(-1===i.indexOf("?")?"?":"&")+t}}var i=y.shim,r={paths:!0,bundles:!0,config:!0,map:!0};eachProp(e,function(e,t){r[t]?(y[t]||(y[t]={}),mixin(y[t],e,!0,!0)):y[t]=e}),e.bundles&&eachProp(e.bundles,function(e,t){each(e,function(e){e!==t&&(R[e]=t)})}),e.shim&&(eachProp(e.shim,function(e,t){isArray(e)&&(e={deps:e}),!e.exports&&!e.init||e.exportsFn||(e.exportsFn=q.makeShimExports(e)),i[t]=e}),y.shim=i),e.packages&&each(e.packages,function(e){var t;t=(e="string"==typeof e?{name:e}:e).name,e.location&&(y.paths[t]=e.location),y.pkgs[t]=e.name+"/"+(e.main||"main").replace(currDirRegExp,"").replace(jsSuffixRegExp,"")}),eachProp(S,function(e,t){e.inited||e.map.unnormalized||(e.map=a(t,null,!0))}),(e.deps||e.callback)&&q.require(e.deps||[],e.callback)},makeShimExports:function(e){return function(){var t;return e.init&&(t=e.init.apply(global,arguments)),t||e.exports&&getGlobal(e.exports)}},makeRequire:function(t,n){function o(i,r,u){var d,p,f;return n.enableBuildCallback&&r&&isFunction(r)&&(r.__requireJsBuild=!0),"string"==typeof i?isFunction(r)?c(makeError("requireargs","Invalid require call"),u):t&&hasProp(E,i)?E[i](S[t.id]):req.get?req.get(q,i,t,o):(p=a(i,t,!1,!0),d=p.id,hasProp(j,d)?j[d]:c(makeError("notloaded",'Module name "'+d+'" has not been loaded yet for context: '+e+(t?"":". Use require([])")))):(v(),q.nextTick(function(){v(),(f=s(a(null,t))).skipMap=n.skipMap,f.init(i,r,u,{enabled:!0}),l()}),o)}return n=n||{},mixin(o,{isBrowser:isBrowser,toUrl:function(e){var r,n=e.lastIndexOf("."),o=e.split("/")[0],a="."===o||".."===o;return-1!==n&&(!a||n>1)&&(r=e.substring(n,e.length),e=e.substring(0,n)),q.nameToUrl(i(e,t&&t.id,!0),r,!0)},defined:function(e){return hasProp(j,a(e,t,!1,!0).id)},specified:function(e){return e=a(e,t,!1,!0).id,hasProp(j,e)||hasProp(S,e)}}),t||(o.undef=function(e){d();var i=a(e,t,!0),n=getOwn(S,e);n.undefed=!0,r(e),delete j[e],delete P[i.url],delete M[e],eachReverse(O,function(t,i){t[0]===e&&O.splice(i,1)}),delete q.defQueueMap[e],n&&(n.events.defined&&(M[e]=n.events),p(e))}),o},enable:function(e){getOwn(S,e.id)&&s(e).enable()},completeLoad:function(e){var t,i,r,o=getOwn(y.shim,e)||{},a=o.exports;for(d();O.length;){if(null===(i=O.shift())[0]){if(i[0]=e,t)break;t=!0}else i[0]===e&&(t=!0);h(i)}if(q.defQueueMap={},r=getOwn(S,e),!t&&!hasProp(j,e)&&r&&!r.inited){if(!(!y.enforceDefine||a&&getGlobal(a)))return n(e)?void 0:c(makeError("nodefine","No define call for "+e,null,[e]));h([e,o.deps||[],o.exportsFn])}l()},nameToUrl:function(e,t,i){var r,n,o,a,s,u,c,d=getOwn(y.pkgs,e);if(d&&(e=d),c=getOwn(R,e))return q.nameToUrl(c,t,i);if(req.jsExtRegExp.test(e))s=e+(t||"");else{for(r=y.paths,o=(n=e.split("/")).length;o>0;o-=1)if(a=n.slice(0,o).join("/"),u=getOwn(r,a)){isArray(u)&&(u=u[0]),n.splice(0,o,u);break}s=n.join("/"),s=("/"===(s+=t||(/^data\:|^blob\:|\?/.test(s)||i?"":".js")).charAt(0)||s.match(/^[\w\+\.\-]+:/)?"":y.baseUrl)+s}return y.urlArgs&&!/^blob\:/.test(s)?s+y.urlArgs(e,s):s},load:function(e,t){req.load(q,e,t)},execCb:function(e,t,i,r){return t.apply(r,i)},onScriptLoad:function(e){if("load"===e.type||readyRegExp.test((e.currentTarget||e.srcElement).readyState)){interactiveScript=null;var t=g(e);q.completeLoad(t.id)}},onScriptError:function(e){var t=g(e);if(!n(t.id)){var i=[];return eachProp(S,function(e,r){0!==r.indexOf("_@r")&&each(e.depMaps,function(e){if(e.id===t.id)return i.push(r),!0})}),c(makeError("scripterror",'Script error for "'+t.id+(i.length?'", needed by: '+i.join(", "):'"'),e,[t.id]))}}},q.require=q.makeRequire(),q}function getInteractiveScript(){return interactiveScript&&"interactive"===interactiveScript.readyState?interactiveScript:(eachReverse(scripts(),function(e){if("interactive"===e.readyState)return interactiveScript=e}),interactiveScript)}var req,s,head,baseElement,dataMain,src,interactiveScript,currentlyAddingScript,mainScript,subPath,version="2.3.5",commentRegExp=/\/\*[\s\S]*?\*\/|([^:"'=]|^)\/\/.*$/gm,cjsRequireRegExp=/[^.]\s*require\s*\(\s*["']([^'"\s]+)["']\s*\)/g,jsSuffixRegExp=/\.js$/,currDirRegExp=/^\.\//,op=Object.prototype,ostring=op.toString,hasOwn=op.hasOwnProperty,isBrowser=!("undefined"==typeof window||"undefined"==typeof navigator||!window.document),isWebWorker=!isBrowser&&"undefined"!=typeof importScripts,readyRegExp=isBrowser&&"PLAYSTATION 3"===navigator.platform?/^complete$/:/^(complete|loaded)$/,defContextName="_",isOpera="undefined"!=typeof opera&&"[object Opera]"===opera.toString(),contexts={},cfg={},globalDefQueue=[],useInteractive=!1;if(void 0===define){if(void 0!==requirejs){if(isFunction(requirejs))return;cfg=requirejs,requirejs=void 0}void 0===require||isFunction(require)||(cfg=require,require=void 0),req=requirejs=function(e,t,i,r){var n,o,a=defContextName;return isArray(e)||"string"==typeof e||(o=e,isArray(t)?(e=t,t=i,i=r):e=[]),o&&o.context&&(a=o.context),(n=getOwn(contexts,a))||(n=contexts[a]=req.s.newContext(a)),o&&n.configure(o),n.require(e,t,i)},req.config=function(e){return req(e)},req.nextTick=void 0!==setTimeout?function(e){setTimeout(e,4)}:function(e){e()},require||(require=req),req.version=version,req.jsExtRegExp=/^\/|:|\?|\.js$/,req.isBrowser=isBrowser,s=req.s={contexts:contexts,newContext:newContext},req({}),each(["toUrl","undef","defined","specified"],function(e){req[e]=function(){var t=contexts[defContextName];return t.require[e].apply(t,arguments)}}),isBrowser&&(head=s.head=document.getElementsByTagName("head")[0],(baseElement=document.getElementsByTagName("base")[0])&&(head=s.head=baseElement.parentNode)),req.onError=defaultOnError,req.createNode=function(e,t,i){var r=e.xhtml?document.createElementNS("http://www.w3.org/1999/xhtml","html:script"):document.createElement("script");return r.type=e.scriptType||"text/javascript",r.charset="utf-8",r.async=!0,r},req.load=function(e,t,i){var r,n=e&&e.config||{};if(isBrowser)return(r=req.createNode(n,t,i)).setAttribute("data-requirecontext",e.contextName),r.setAttribute("data-requiremodule",t),!r.attachEvent||r.attachEvent.toString&&r.attachEvent.toString().indexOf("[native code")<0||isOpera?(r.addEventListener("load",e.onScriptLoad,!1),r.addEventListener("error",e.onScriptError,!1)):(useInteractive=!0,r.attachEvent("onreadystatechange",e.onScriptLoad)),r.src=i,n.onNodeCreated&&n.onNodeCreated(r,n,t,i),currentlyAddingScript=r,baseElement?head.insertBefore(r,baseElement):head.appendChild(r),currentlyAddingScript=null,r;if(isWebWorker)try{setTimeout(function(){},0),importScripts(i),e.completeLoad(t)}catch(r){e.onError(makeError("importscripts","importScripts failed for "+t+" at "+i,r,[t]))}},isBrowser&&!cfg.skipDataMain&&eachReverse(scripts(),function(e){if(head||(head=e.parentNode),dataMain=e.getAttribute("data-main"))return mainScript=dataMain,cfg.baseUrl||-1!==mainScript.indexOf("!")||(src=mainScript.split("/"),mainScript=src.pop(),subPath=src.length?src.join("/")+"/":"./",cfg.baseUrl=subPath),mainScript=mainScript.replace(jsSuffixRegExp,""),req.jsExtRegExp.test(mainScript)&&(mainScript=dataMain),cfg.deps=cfg.deps?cfg.deps.concat(mainScript):[mainScript],!0}),define=function(e,t,i){var r,n;"string"!=typeof e&&(i=t,t=e,e=null),isArray(t)||(i=t,t=null),!t&&isFunction(i)&&(t=[],i.length&&(i.toString().replace(commentRegExp,commentReplace).replace(cjsRequireRegExp,function(e,i){t.push(i)}),t=(1===i.length?["require"]:["require","exports","module"]).concat(t))),useInteractive&&(r=currentlyAddingScript||getInteractiveScript())&&(e||(e=r.getAttribute("data-requiremodule")),n=contexts[r.getAttribute("data-requirecontext")]),n?(n.defQueue.push([e,t,i]),n.defQueueMap[e]=!0):globalDefQueue.push([e,t,i])},define.amd={jQuery:!0},req.exec=function(text){return eval(text)},req(cfg)}}(this,"undefined"==typeof setTimeout?void 0:setTimeout); -------------------------------------------------------------------------------- /scripts/lib/saveSvgAsPng.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var out$ = typeof exports != 'undefined' && exports || typeof define != 'undefined' && {} || this; 3 | 4 | var doctype = ']>'; 5 | 6 | function isElement(obj) { 7 | return obj instanceof HTMLElement || obj instanceof SVGElement; 8 | } 9 | 10 | function requireDomNode(el) { 11 | if (!isElement(el)) { 12 | throw new Error('an HTMLElement or SVGElement is required; got ' + el); 13 | } 14 | } 15 | 16 | function isExternal(url) { 17 | return url && url.lastIndexOf('http',0) == 0 && url.lastIndexOf(window.location.host) == -1; 18 | } 19 | 20 | function inlineImages(el, callback) { 21 | requireDomNode(el); 22 | 23 | var images = el.querySelectorAll('image'), 24 | left = images.length, 25 | checkDone = function() { 26 | if (left === 0) { 27 | callback(); 28 | } 29 | }; 30 | 31 | checkDone(); 32 | for (var i = 0; i < images.length; i++) { 33 | (function(image) { 34 | var href = image.getAttributeNS("http://www.w3.org/1999/xlink", "href"); 35 | if (href) { 36 | if (isExternal(href.value)) { 37 | console.warn("Cannot render embedded images linking to external hosts: "+href.value); 38 | return; 39 | } 40 | } 41 | var canvas = document.createElement('canvas'); 42 | var ctx = canvas.getContext('2d'); 43 | var img = new Image(); 44 | img.crossOrigin="anonymous"; 45 | href = href || image.getAttribute('href'); 46 | if (href) { 47 | img.src = href; 48 | img.onload = function() { 49 | canvas.width = img.width; 50 | canvas.height = img.height; 51 | ctx.drawImage(img, 0, 0); 52 | image.setAttributeNS("http://www.w3.org/1999/xlink", "href", canvas.toDataURL('image/png')); 53 | left--; 54 | checkDone(); 55 | } 56 | img.onerror = function() { 57 | console.log("Could not load "+href); 58 | left--; 59 | checkDone(); 60 | } 61 | } else { 62 | left--; 63 | checkDone(); 64 | } 65 | })(images[i]); 66 | } 67 | } 68 | 69 | function styles(el, options, cssLoadedCallback) { 70 | var selectorRemap = options.selectorRemap; 71 | var modifyStyle = options.modifyStyle; 72 | var modifyCss = options.modifyCss || function(selector, properties) { 73 | var selector = selectorRemap ? selectorRemap(selector) : selector; 74 | var cssText = modifyStyle ? modifyStyle(properties) : properties; 75 | return selector + " { " + cssText + " }\n"; 76 | }; 77 | var css = ""; 78 | 79 | // Each font that has an external link is saved into queue, and processed asynchronously. 80 | var fontsQueue = []; 81 | var sheets = document.styleSheets; 82 | for (var i = 0; i < sheets.length; i++) { 83 | try { 84 | var rules = sheets[i].cssRules; 85 | } catch (e) { 86 | console.warn("Stylesheet could not be loaded: "+sheets[i].href); 87 | continue; 88 | } 89 | 90 | if (rules != null) { 91 | for (var j = 0, match; j < rules.length; j++, match = null) { 92 | var rule = rules[j]; 93 | if (typeof(rule.style) != "undefined") { 94 | var selectorText; 95 | 96 | try { 97 | selectorText = rule.selectorText; 98 | } catch(err) { 99 | console.warn('The following CSS rule has an invalid selector: "' + rule + '"', err); 100 | } 101 | 102 | try { 103 | if (selectorText) { 104 | match = el.querySelector(selectorText) || (el.parentNode && el.parentNode.querySelector(selectorText)); 105 | } 106 | } catch(err) { 107 | console.warn('Invalid CSS selector "' + selectorText + '"', err); 108 | } 109 | 110 | if (match) { 111 | css += modifyCss(rule.selectorText, rule.style.cssText); 112 | } else if(rule.cssText.match(/^@font-face/)) { 113 | // below we are trying to find matches to external link. E.g. 114 | // @font-face { 115 | // // ... 116 | // src: local('Abel'), url(https://fonts.gstatic.com/s/abel/v6/UzN-iejR1VoXU2Oc-7LsbvesZW2xOQ-xsNqO47m55DA.woff2); 117 | // } 118 | // 119 | // This regex will save extrnal link into first capture group 120 | var fontUrlRegexp = /url\(["']?(.+?)["']?\)/; 121 | // TODO: This needs to be changed to support multiple url declarations per font. 122 | var fontUrlMatch = rule.cssText.match(fontUrlRegexp); 123 | 124 | var externalFontUrl = (fontUrlMatch && fontUrlMatch[1]) || ''; 125 | var fontUrlIsDataURI = externalFontUrl.match(/^data:/); 126 | if (fontUrlIsDataURI) { 127 | // We should ignore data uri - they are already embedded 128 | externalFontUrl = ''; 129 | } 130 | 131 | if (externalFontUrl === 'about:blank') { 132 | // no point trying to load this 133 | externalFontUrl = ''; 134 | } 135 | 136 | if (externalFontUrl) { 137 | // okay, we are lucky. We can fetch this font later 138 | 139 | //handle url if relative 140 | if (externalFontUrl.startsWith('../')) { 141 | externalFontUrl = sheets[i].href + '/../' + externalFontUrl 142 | } else if (externalFontUrl.startsWith('./')) { 143 | externalFontUrl = sheets[i].href + '/.' + externalFontUrl 144 | } 145 | 146 | fontsQueue.push({ 147 | text: rule.cssText, 148 | // Pass url regex, so that once font is downladed, we can run `replace()` on it 149 | fontUrlRegexp: fontUrlRegexp, 150 | format: getFontMimeTypeFromUrl(externalFontUrl), 151 | url: externalFontUrl 152 | }); 153 | } else { 154 | // otherwise, use previous logic 155 | css += rule.cssText + '\n'; 156 | } 157 | } 158 | } 159 | } 160 | } 161 | } 162 | 163 | // Now all css is processed, it's time to handle scheduled fonts 164 | processFontQueue(fontsQueue); 165 | 166 | function getFontMimeTypeFromUrl(fontUrl) { 167 | var supportedFormats = { 168 | 'woff2': 'font/woff2', 169 | 'woff': 'font/woff', 170 | 'otf': 'application/x-font-opentype', 171 | 'ttf': 'application/x-font-ttf', 172 | 'eot': 'application/vnd.ms-fontobject', 173 | 'sfnt': 'application/font-sfnt', 174 | 'svg': 'image/svg+xml' 175 | }; 176 | var extensions = Object.keys(supportedFormats); 177 | for (var i = 0; i < extensions.length; ++i) { 178 | var extension = extensions[i]; 179 | // TODO: This is not bullet proof, it needs to handle edge cases... 180 | if (fontUrl.indexOf('.' + extension) > 0) { 181 | return supportedFormats[extension]; 182 | } 183 | } 184 | 185 | // If you see this error message, you probably need to update code above. 186 | console.error('Unknown font format for ' + fontUrl+ '; Fonts may not be working correctly'); 187 | return 'application/octet-stream'; 188 | } 189 | 190 | function processFontQueue(queue) { 191 | if (queue.length > 0) { 192 | // load fonts one by one until we have anything in the queue: 193 | var font = queue.pop(); 194 | processNext(font); 195 | } else { 196 | // no more fonts to load. 197 | cssLoadedCallback(css); 198 | } 199 | 200 | function processNext(font) { 201 | // TODO: This could benefit from caching. 202 | var oReq = new XMLHttpRequest(); 203 | oReq.addEventListener('load', fontLoaded); 204 | oReq.addEventListener('error', transferFailed); 205 | oReq.addEventListener('abort', transferFailed); 206 | oReq.open('GET', font.url); 207 | oReq.responseType = 'arraybuffer'; 208 | oReq.send(); 209 | 210 | function fontLoaded() { 211 | // TODO: it may be also worth to wait until fonts are fully loaded before 212 | // attempting to rasterize them. (e.g. use https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet ) 213 | var fontBits = oReq.response; 214 | var fontInBase64 = arrayBufferToBase64(fontBits); 215 | updateFontStyle(font, fontInBase64); 216 | } 217 | 218 | function transferFailed(e) { 219 | console.warn('Failed to load font from: ' + font.url); 220 | console.warn(e) 221 | css += font.text + '\n'; 222 | processFontQueue(queue); 223 | } 224 | 225 | function updateFontStyle(font, fontInBase64) { 226 | var dataUrl = 'url("data:' + font.format + ';base64,' + fontInBase64 + '")'; 227 | css += font.text.replace(font.fontUrlRegexp, dataUrl) + '\n'; 228 | 229 | // schedule next font download on next tick. 230 | setTimeout(function() { 231 | processFontQueue(queue) 232 | }, 0); 233 | } 234 | 235 | } 236 | } 237 | 238 | function arrayBufferToBase64(buffer) { 239 | var binary = ''; 240 | var bytes = new Uint8Array(buffer); 241 | var len = bytes.byteLength; 242 | 243 | for (var i = 0; i < len; i++) { 244 | binary += String.fromCharCode(bytes[i]); 245 | } 246 | 247 | return window.btoa(binary); 248 | } 249 | } 250 | 251 | function getDimension(el, clone, dim) { 252 | var v = (el.viewBox && el.viewBox.baseVal && el.viewBox.baseVal[dim]) || 253 | (clone.getAttribute(dim) !== null && !clone.getAttribute(dim).match(/%$/) && parseInt(clone.getAttribute(dim))) || 254 | el.getBoundingClientRect()[dim] || 255 | parseInt(clone.style[dim]) || 256 | parseInt(window.getComputedStyle(el).getPropertyValue(dim)); 257 | return (typeof v === 'undefined' || v === null || isNaN(parseFloat(v))) ? 0 : v; 258 | } 259 | 260 | function reEncode(data) { 261 | data = encodeURIComponent(data); 262 | data = data.replace(/%([0-9A-F]{2})/g, function(match, p1) { 263 | var c = String.fromCharCode('0x'+p1); 264 | return c === '%' ? '%25' : c; 265 | }); 266 | return decodeURIComponent(data); 267 | } 268 | 269 | out$.prepareSvg = function(el, options, cb) { 270 | requireDomNode(el); 271 | 272 | options = options || {}; 273 | options.scale = options.scale || 1; 274 | options.responsive = options.responsive || false; 275 | var xmlns = "http://www.w3.org/2000/xmlns/"; 276 | 277 | inlineImages(el, function() { 278 | var outer = document.createElement("div"); 279 | var clone = el.cloneNode(true); 280 | var width, height; 281 | if(el.tagName == 'svg') { 282 | width = options.width || getDimension(el, clone, 'width'); 283 | height = options.height || getDimension(el, clone, 'height'); 284 | } else if(el.getBBox) { 285 | var box = el.getBBox(); 286 | width = box.x + box.width; 287 | height = box.y + box.height; 288 | clone.setAttribute('transform', clone.getAttribute('transform').replace(/translate\(.*?\)/, '')); 289 | 290 | var svg = document.createElementNS('http://www.w3.org/2000/svg','svg') 291 | svg.appendChild(clone) 292 | clone = svg; 293 | } else { 294 | console.error('Attempted to render non-SVG element', el); 295 | return; 296 | } 297 | 298 | clone.setAttribute("version", "1.1"); 299 | if (!clone.getAttribute('xmlns')) { 300 | clone.setAttributeNS(xmlns, "xmlns", "http://www.w3.org/2000/svg"); 301 | } 302 | if (!clone.getAttribute('xmlns:xlink')) { 303 | clone.setAttributeNS(xmlns, "xmlns:xlink", "http://www.w3.org/1999/xlink"); 304 | } 305 | 306 | if (options.responsive) { 307 | clone.removeAttribute('width'); 308 | clone.removeAttribute('height'); 309 | clone.setAttribute('preserveAspectRatio', 'xMinYMin meet'); 310 | } else { 311 | clone.setAttribute("width", width * options.scale); 312 | clone.setAttribute("height", height * options.scale); 313 | } 314 | 315 | clone.setAttribute("viewBox", [ 316 | options.left || 0, 317 | options.top || 0, 318 | width, 319 | height 320 | ].join(" ")); 321 | 322 | var fos = clone.querySelectorAll('foreignObject > *'); 323 | for (var i = 0; i < fos.length; i++) { 324 | if (!fos[i].getAttribute('xmlns')) { 325 | fos[i].setAttributeNS(xmlns, "xmlns", "http://www.w3.org/1999/xhtml"); 326 | } 327 | } 328 | 329 | outer.appendChild(clone); 330 | 331 | // In case of custom fonts we need to fetch font first, and then inline 332 | // its url into data-uri format (encode as base64). That's why style 333 | // processing is done asynchonously. Once all inlining is finshed 334 | // cssLoadedCallback() is called. 335 | styles(el, options, cssLoadedCallback); 336 | 337 | function cssLoadedCallback(css) { 338 | // here all fonts are inlined, so that we can render them properly. 339 | var s = document.createElement('style'); 340 | s.setAttribute('type', 'text/css'); 341 | s.innerHTML = ""; 342 | var defs = document.createElement('defs'); 343 | defs.appendChild(s); 344 | clone.insertBefore(defs, clone.firstChild); 345 | 346 | if (cb) { 347 | var outHtml = outer.innerHTML; 348 | outHtml = outHtml.replace(/NS\d+:href/gi, 'xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href'); 349 | cb(outHtml, width, height); 350 | } 351 | } 352 | }); 353 | } 354 | 355 | out$.svgAsDataUri = function(el, options, cb) { 356 | out$.prepareSvg(el, options, function(svg) { 357 | var uri = 'data:image/svg+xml;base64,' + window.btoa(reEncode(doctype + svg)); 358 | if (cb) { 359 | cb(uri); 360 | } 361 | }); 362 | } 363 | 364 | out$.svgAsPngUri = function(el, options, cb) { 365 | requireDomNode(el); 366 | 367 | options = options || {}; 368 | options.encoderType = options.encoderType || 'image/png'; 369 | options.encoderOptions = options.encoderOptions || 0.8; 370 | 371 | var convertToPng = function(src, w, h) { 372 | var canvas = document.createElement('canvas'); 373 | var context = canvas.getContext('2d'); 374 | canvas.width = w; 375 | canvas.height = h; 376 | 377 | var pixelRatio = window.devicePixelRatio || 1; 378 | 379 | canvas.style.width = canvas.width+'px'; 380 | canvas.style.height = canvas.height+'px'; 381 | canvas.width *= pixelRatio; 382 | canvas.height *= pixelRatio; 383 | 384 | context.setTransform(pixelRatio,0,0,pixelRatio,0,0); 385 | 386 | if(options.canvg) { 387 | options.canvg(canvas, src); 388 | } else { 389 | context.drawImage(src, 0, 0); 390 | } 391 | 392 | if(options.backgroundColor){ 393 | context.globalCompositeOperation = 'destination-over'; 394 | context.fillStyle = options.backgroundColor; 395 | context.fillRect(0, 0, canvas.width, canvas.height); 396 | } 397 | 398 | var png; 399 | try { 400 | png = canvas.toDataURL(options.encoderType, options.encoderOptions); 401 | } catch (e) { 402 | if ((typeof SecurityError !== 'undefined' && e instanceof SecurityError) || e.name == "SecurityError") { 403 | console.error("Rendered SVG images cannot be downloaded in this browser."); 404 | return; 405 | } else { 406 | throw e; 407 | } 408 | } 409 | cb(png); 410 | } 411 | 412 | if(options.canvg) { 413 | out$.prepareSvg(el, options, convertToPng); 414 | } else { 415 | out$.svgAsDataUri(el, options, function(uri) { 416 | var image = new Image(); 417 | 418 | image.onload = function() { 419 | convertToPng(image, image.width, image.height); 420 | } 421 | 422 | image.onerror = function() { 423 | console.error( 424 | 'There was an error loading the data URI as an image on the following SVG\n', 425 | window.atob(uri.slice(26)), '\n', 426 | "Open the following link to see browser's diagnosis\n", 427 | uri); 428 | } 429 | 430 | image.src = uri; 431 | }); 432 | } 433 | } 434 | 435 | out$.download = function(name, uri) { 436 | if (navigator.msSaveOrOpenBlob) { 437 | navigator.msSaveOrOpenBlob(uriToBlob(uri), name); 438 | } else { 439 | var saveLink = document.createElement('a'); 440 | var downloadSupported = 'download' in saveLink; 441 | if (downloadSupported) { 442 | saveLink.download = name; 443 | saveLink.style.display = 'none'; 444 | document.body.appendChild(saveLink); 445 | try { 446 | var blob = uriToBlob(uri); 447 | var url = URL.createObjectURL(blob); 448 | saveLink.href = url; 449 | saveLink.onclick = function() { 450 | requestAnimationFrame(function() { 451 | URL.revokeObjectURL(url); 452 | }) 453 | }; 454 | } catch (e) { 455 | console.warn('This browser does not support object URLs. Falling back to string URL.'); 456 | saveLink.href = uri; 457 | } 458 | saveLink.click(); 459 | document.body.removeChild(saveLink); 460 | } 461 | else { 462 | window.open(uri, '_temp', 'menubar=no,toolbar=no,status=no'); 463 | } 464 | } 465 | } 466 | 467 | function uriToBlob(uri) { 468 | var byteString = window.atob(uri.split(',')[1]); 469 | var mimeString = uri.split(',')[0].split(':')[1].split(';')[0] 470 | var buffer = new ArrayBuffer(byteString.length); 471 | var intArray = new Uint8Array(buffer); 472 | for (var i = 0; i < byteString.length; i++) { 473 | intArray[i] = byteString.charCodeAt(i); 474 | } 475 | return new Blob([buffer], {type: mimeString}); 476 | } 477 | 478 | out$.saveSvg = function(el, name, options) { 479 | requireDomNode(el); 480 | 481 | options = options || {}; 482 | out$.svgAsDataUri(el, options, function(uri) { 483 | out$.download(name, uri); 484 | }); 485 | } 486 | 487 | out$.saveSvgAsPng = function(el, name, options) { 488 | requireDomNode(el); 489 | 490 | options = options || {}; 491 | out$.svgAsPngUri(el, options, function(uri) { 492 | out$.download(name, uri); 493 | }); 494 | } 495 | 496 | // if define is defined create as an AMD module 497 | if (typeof define !== 'undefined') { 498 | define(function() { 499 | return out$; 500 | }); 501 | } 502 | 503 | })(); 504 | -------------------------------------------------------------------------------- /src/facetReducer.ts: -------------------------------------------------------------------------------- 1 | import { ColorReducer } from "./colorreductionmanagement"; 2 | import { delay, IMap, RGB } from "./common"; 3 | import { FacetCreator } from "./facetCreator"; 4 | import { Facet, FacetResult } from "./facetmanagement"; 5 | import { BooleanArray2D, Uint8Array2D } from "./structs/typedarrays"; 6 | 7 | export class FacetReducer { 8 | 9 | /** 10 | * Remove all facets that have a pointCount smaller than the given number. 11 | */ 12 | public static async reduceFacets(smallerThan: number, removeFacetsFromLargeToSmall: boolean, maximumNumberOfFacets: number, colorsByIndex: RGB[], facetResult: FacetResult, imgColorIndices: Uint8Array2D, onUpdate: ((progress: number) => void) | null = null) { 13 | const visitedCache = new BooleanArray2D(facetResult.width, facetResult.height); 14 | 15 | // build the color distance matrix, which describes the distance of each color to each other 16 | const colorDistances: number[][] = ColorReducer.buildColorDistanceMatrix(colorsByIndex); 17 | 18 | // process facets from large to small. This results in better consistency with the original image 19 | // because the small facets act as boundary for the large merges keeping them mostly in place of where they should remain 20 | // then afterwards the smaller ones are deleted which will just end up completely isolated and thus entirely replaced 21 | // with the outer facet. But then again, what do I know, I'm just a comment. 22 | const facetProcessingOrder = facetResult.facets.filter((f) => f != null).slice(0).sort((a, b) => b!.pointCount > a!.pointCount ? 1 : (b!.pointCount < a!.pointCount ? -1 : 0)).map((f) => f!.id); 23 | 24 | if (!removeFacetsFromLargeToSmall) { 25 | facetProcessingOrder.reverse(); 26 | } 27 | 28 | let curTime = new Date().getTime(); 29 | for (let fidx: number = 0; fidx < facetProcessingOrder.length; fidx++) { 30 | const f = facetResult.facets[facetProcessingOrder[fidx]]; 31 | // facets can be removed by merging by others due to a previous facet deletion 32 | if (f != null && f.pointCount < smallerThan) { 33 | FacetReducer.deleteFacet(f.id, facetResult, imgColorIndices, colorDistances, visitedCache); 34 | 35 | if (new Date().getTime() - curTime > 500) { 36 | curTime = new Date().getTime(); 37 | await delay(0); 38 | if (onUpdate != null) { 39 | onUpdate(0.5 * fidx / facetProcessingOrder.length); 40 | } 41 | } 42 | } 43 | 44 | } 45 | 46 | let facetCount = facetResult.facets.filter(f => f != null).length; 47 | if (facetCount > maximumNumberOfFacets) { 48 | console.log(`There are still ${facetCount} facets, more than the maximum of ${maximumNumberOfFacets}. Removing the smallest facets`); 49 | } 50 | 51 | const startFacetCount = facetCount; 52 | while (facetCount > maximumNumberOfFacets) { 53 | 54 | // because facets can be merged, reevaluate the order of facets to make sure the smallest one is removed 55 | // this is slower but more accurate 56 | const facetProcessingOrder = facetResult.facets.filter((f) => f != null).slice(0) 57 | .sort((a, b) => b!.pointCount > a!.pointCount ? 1 : (b!.pointCount < a!.pointCount ? -1 : 0)) 58 | .map((f) => f!.id) 59 | .reverse(); 60 | 61 | const facetToRemove = facetResult.facets[facetProcessingOrder[0]]; 62 | 63 | FacetReducer.deleteFacet(facetToRemove!.id, facetResult, imgColorIndices, colorDistances, visitedCache); 64 | facetCount = facetResult.facets.filter(f => f != null).length; 65 | 66 | if (new Date().getTime() - curTime > 500) { 67 | curTime = new Date().getTime(); 68 | await delay(0); 69 | if (onUpdate != null) { 70 | onUpdate(0.5 + 0.5 - (facetCount - maximumNumberOfFacets) / (startFacetCount - maximumNumberOfFacets)); 71 | } 72 | } 73 | } 74 | // this.trimFacets(facetResult, imgColorIndices, colorDistances, visitedCache); 75 | 76 | if (onUpdate != null) { 77 | onUpdate(1); 78 | } 79 | } 80 | 81 | // /** 82 | // * Trims facets with narrow paths either horizontally or vertically, potentially splitting the facet into multiple facets 83 | // */ 84 | // public static trimFacets(facetResult: FacetResult, imgColorIndices: Uint8Array2D, colorDistances: number[][], visitedArrayCache: BooleanArray2D) { 85 | // for (const facet of facetResult.facets) { 86 | // if (facet !== null) { 87 | 88 | // const facetPointsToReallocate: Point[] = []; 89 | 90 | // for (let y: number = facet.bbox.minY; y <= facet.bbox.maxY; y++) { 91 | // for (let x: number = facet.bbox.minX; x <= facet.bbox.maxX; x++) { 92 | // if (x > 0 && y > 0 && x < facetResult.width - 1 && y < facetResult.height - 1 && 93 | // facetResult.facetMap.get(x, y) === facet.id) { 94 | 95 | // // check if isolated horizontally 96 | // const top = facetResult.facetMap.get(x, y - 1); 97 | // const bottom = facetResult.facetMap.get(x, y + 1); 98 | 99 | // if (top !== facet.id && bottom !== facet.id) { 100 | // // . ? . 101 | // // . F . 102 | // // . ? . 103 | // // mark pixel of facet that it should be removed 104 | // facetPointsToReallocate.push(new Point(x, y)); 105 | 106 | // const closestNeighbour = FacetReducer.getClosestNeighbourForPixel(facet, facetResult, x, y, colorDistances); 107 | // // copy over color of closest neighbour 108 | // imgColorIndices.set(x, y, facetResult.facets[closestNeighbour]!.color); 109 | // console.log("Flagged " + x + "," + y + " to trim"); 110 | // } 111 | // } 112 | // } 113 | // } 114 | 115 | // if (facetPointsToReallocate.length > 0) { 116 | // FacetReducer.rebuildForFacetChange(visitedArrayCache, facet, imgColorIndices, facetResult); 117 | // } 118 | // } 119 | // } 120 | // } 121 | 122 | /** 123 | * Deletes a facet. All points belonging to the facet are moved to the nearest neighbour facet 124 | * based on the distance of the neighbour border points. This results in a voronoi like filling in of the 125 | * void the deletion made 126 | */ 127 | private static deleteFacet(facetIdToRemove: number, facetResult: FacetResult, imgColorIndices: Uint8Array2D, colorDistances: number[][], visitedArrayCache: BooleanArray2D) { 128 | const facetToRemove = facetResult.facets[facetIdToRemove]; 129 | if (facetToRemove === null) { // already removed 130 | return; 131 | } 132 | 133 | if (facetToRemove.neighbourFacetsIsDirty) { 134 | FacetCreator.buildFacetNeighbour(facetToRemove, facetResult); 135 | } 136 | 137 | if (facetToRemove.neighbourFacets!.length > 0) { 138 | // there are many small facets, it's faster to just iterate over all points within its bounding box 139 | // and seeing which belong to the facet than to keep track of the inner points (along with the border points) 140 | // per facet, because that generates a lot of extra heap objects that need to be garbage collected each time 141 | // a facet is rebuilt 142 | for (let j: number = facetToRemove.bbox.minY; j <= facetToRemove.bbox.maxY; j++) { 143 | for (let i: number = facetToRemove.bbox.minX; i <= facetToRemove.bbox.maxX; i++) { 144 | if (facetResult.facetMap.get(i, j) === facetToRemove.id) { 145 | const closestNeighbour = FacetReducer.getClosestNeighbourForPixel(facetToRemove, facetResult, i, j, colorDistances); 146 | if (closestNeighbour !== -1) { 147 | // copy over color of closest neighbour 148 | imgColorIndices.set(i, j, facetResult.facets[closestNeighbour]!.color); 149 | } else { 150 | console.warn(`No closest neighbour found for point ${i},${j}`); 151 | } 152 | } 153 | } 154 | } 155 | } else { 156 | console.warn(`Facet ${facetToRemove.id} does not have any neighbours`); 157 | } 158 | 159 | // Rebuild all the neighbour facets that have been changed. While it could probably be faster by just adding the points manually 160 | // to the facet map and determine if the border points are still valid, it's more complex than that. It's possible that due to the change in points 161 | // that 2 neighbours of the same colors have become linked and need to merged as well. So it's easier to just rebuild the entire facet 162 | FacetReducer.rebuildForFacetChange(visitedArrayCache, facetToRemove, imgColorIndices, facetResult); 163 | 164 | // now mark the facet to remove as deleted 165 | facetResult.facets[facetToRemove.id] = null; 166 | } 167 | 168 | private static rebuildForFacetChange(visitedArrayCache: BooleanArray2D, facet: Facet, imgColorIndices: Uint8Array2D, facetResult: FacetResult) { 169 | FacetReducer.rebuildChangedNeighbourFacets(visitedArrayCache, facet, imgColorIndices, facetResult); 170 | 171 | // sanity check: make sure that all points have been replaced by neighbour facets. It's possible that some points will have 172 | // been left out because there is no continuity with the neighbour points 173 | // this occurs for diagonal points to the neighbours and more often when the closest 174 | // color is chosen when distances are equal. 175 | // It's probably possible to enforce that this will never happen in the above code but 176 | // this is a constraint that is expensive to enforce and doesn't happen all that much 177 | // so instead try and merge if with any of its direct neighbours if possible 178 | let needsToRebuild = false; 179 | for (let y: number = facet.bbox.minY; y <= facet.bbox.maxY; y++) { 180 | for (let x: number = facet.bbox.minX; x <= facet.bbox.maxX; x++) { 181 | if (facetResult.facetMap.get(x, y) === facet.id) { 182 | console.warn(`Point ${x},${y} was reallocated to neighbours for facet ${facet.id}`); 183 | needsToRebuild = true; 184 | if (x - 1 >= 0 && facetResult.facetMap.get(x - 1, y) !== facet.id && facetResult.facets[facetResult.facetMap.get(x - 1, y)] !== null) { 185 | imgColorIndices.set(x, y, facetResult.facets[facetResult.facetMap.get(x - 1, y)]!.color); 186 | } else if (y - 1 >= 0 && facetResult.facetMap.get(x, y - 1) !== facet.id && facetResult.facets[facetResult.facetMap.get(x, y - 1)] !== null) { 187 | imgColorIndices.set(x, y, facetResult.facets[facetResult.facetMap.get(x, y - 1)]!.color); 188 | } else if (x + 1 < facetResult.width && facetResult.facetMap.get(x + 1, y) !== facet.id && facetResult.facets[facetResult.facetMap.get(x + 1, y)] !== null) { 189 | imgColorIndices.set(x, y, facetResult.facets[facetResult.facetMap.get(x + 1, y)]!.color); 190 | } else if (y + 1 < facetResult.height && facetResult.facetMap.get(x, y + 1) !== facet.id && facetResult.facets[facetResult.facetMap.get(x, y + 1)] !== null) { 191 | imgColorIndices.set(x, y, facetResult.facets[facetResult.facetMap.get(x, y + 1)]!.color); 192 | } else { 193 | console.error(`Unable to reallocate point ${x},${y}`); 194 | } 195 | } 196 | } 197 | } 198 | // now we need to go through the thing again to build facets and update the neighbours 199 | if (needsToRebuild) { 200 | FacetReducer.rebuildChangedNeighbourFacets(visitedArrayCache, facet, imgColorIndices, facetResult); 201 | } 202 | } 203 | 204 | /** 205 | * Determines the closest neighbour for a given pixel of a facet, based on the closest distance to the neighbour AND the when tied, the closest color 206 | */ 207 | private static getClosestNeighbourForPixel(facetToRemove: Facet, facetResult: FacetResult, x: number, y: number, colorDistances: number[][]) { 208 | let closestNeighbour = -1; 209 | let minDistance = Number.MAX_VALUE; 210 | let minColorDistance = Number.MAX_VALUE; 211 | // ensure the neighbour facets is up to date if it was marked as dirty 212 | if (facetToRemove.neighbourFacetsIsDirty) { 213 | FacetCreator.buildFacetNeighbour(facetToRemove, facetResult); 214 | } 215 | // determine which neighbour will receive the current point based on the distance, and if there are more with the same 216 | // distance, then take the neighbour with the closes color 217 | for (const neighbourIdx of facetToRemove.neighbourFacets!) { 218 | const neighbour = facetResult.facets[neighbourIdx]; 219 | if (neighbour != null) { 220 | for (const bpt of neighbour.borderPoints) { 221 | const distance = bpt.distanceToCoord(x, y); 222 | if (distance < minDistance) { 223 | minDistance = distance; 224 | closestNeighbour = neighbourIdx; 225 | minColorDistance = Number.MAX_VALUE; // reset color distance 226 | } else if (distance === minDistance) { 227 | // if the distance is equal as the min distance 228 | // then see if the neighbour's color is closer to the current color 229 | // note: this causes morepoints to be reallocated to different neighbours 230 | // in the sanity check later, but still yields a better visual result 231 | const colorDistance = colorDistances[facetToRemove.color][neighbour.color]; 232 | if (colorDistance < minColorDistance) { 233 | minColorDistance = colorDistance; 234 | closestNeighbour = neighbourIdx; 235 | } 236 | } 237 | } 238 | } 239 | } 240 | return closestNeighbour; 241 | } 242 | 243 | /** 244 | * Rebuilds the given changed facets 245 | */ 246 | private static rebuildChangedNeighbourFacets(visitedArrayCache: BooleanArray2D, facetToRemove: Facet, imgColorIndices: Uint8Array2D, facetResult: FacetResult) { 247 | const changedNeighboursSet: IMap = {}; 248 | 249 | if (facetToRemove.neighbourFacetsIsDirty) { 250 | FacetCreator.buildFacetNeighbour(facetToRemove, facetResult); 251 | } 252 | 253 | for (const neighbourIdx of facetToRemove.neighbourFacets!) { 254 | const neighbour = facetResult.facets[neighbourIdx]; 255 | if (neighbour != null) { 256 | // re-evaluate facet 257 | // track all the facets that needs to have their neighbour list updated, which is also going to be all the neighbours of the neighbours that are being updated 258 | changedNeighboursSet[neighbourIdx] = true; 259 | 260 | if (neighbour.neighbourFacetsIsDirty) { 261 | FacetCreator.buildFacetNeighbour(neighbour, facetResult); 262 | } 263 | 264 | for (const n of neighbour.neighbourFacets!) { 265 | changedNeighboursSet[n] = true; 266 | } 267 | 268 | // rebuild the neighbour facet 269 | const newFacet = FacetCreator.buildFacet(neighbourIdx, neighbour.color, neighbour.borderPoints[0].x, neighbour.borderPoints[0].y, visitedArrayCache, imgColorIndices, facetResult); 270 | facetResult.facets[neighbourIdx] = newFacet; 271 | 272 | // it's possible that any of the neighbour facets are now overlapping 273 | // because if for example facet Red - Green - Red, Green is removed 274 | // then it will become Red - Red and both facets will overlap 275 | // this means the facet will have 0 points remaining 276 | if (newFacet.pointCount === 0) { 277 | // remove the empty facet as well 278 | facetResult.facets[neighbourIdx] = null; 279 | } 280 | } 281 | } 282 | // reset the visited array for all neighbours 283 | // while the visited array could be recreated per facet to remove, it's quite big and introduces 284 | // a lot of allocation / cleanup overhead. Due to the size of the facets it's usually faster 285 | // to just flag every point of the facet as false again 286 | if (facetToRemove.neighbourFacetsIsDirty) { 287 | FacetCreator.buildFacetNeighbour(facetToRemove, facetResult); 288 | } 289 | 290 | for (const neighbourIdx of facetToRemove.neighbourFacets!) { 291 | const neighbour = facetResult.facets[neighbourIdx]; 292 | if (neighbour != null) { 293 | for (let y: number = neighbour.bbox.minY; y <= neighbour.bbox.maxY; y++) { 294 | for (let x: number = neighbour.bbox.minX; x <= neighbour.bbox.maxX; x++) { 295 | if (facetResult.facetMap.get(x, y) === neighbour.id) { 296 | visitedArrayCache.set(x, y, false); 297 | } 298 | } 299 | } 300 | } 301 | } 302 | // rebuild neighbour array for affected neighbours 303 | for (const k of Object.keys(changedNeighboursSet)) { 304 | if (changedNeighboursSet.hasOwnProperty(k)) { 305 | const neighbourIdx = parseInt(k); 306 | const f = facetResult.facets[neighbourIdx]; 307 | if (f != null) { 308 | // it's a lot faster when deferring the neighbour array updates 309 | // because a lot of facets that are deleted share the same facet neighbours 310 | // and removing the unnecessary neighbour array checks until they it's needed 311 | // speeds things up significantly 312 | // FacetCreator.buildFacetNeighbour(f, facetResult); 313 | f.neighbourFacets = null; 314 | f.neighbourFacetsIsDirty = true; 315 | } 316 | } 317 | } 318 | } 319 | 320 | } 321 | 322 | -------------------------------------------------------------------------------- /src/facetBorderSegmenter.ts: -------------------------------------------------------------------------------- 1 | import { delay } from "./common"; 2 | import { Point } from "./structs/point"; 3 | import { FacetResult, PathPoint, OrientationEnum } from "./facetmanagement"; 4 | 5 | 6 | /** 7 | * Path segment is a segment of a border path that is adjacent to a specific neighbour facet 8 | */ 9 | export class PathSegment { 10 | constructor(public points: PathPoint[], public neighbour: number) { 11 | 12 | } 13 | } 14 | 15 | 16 | /** 17 | * Facet boundary segment describes the matched segment that is shared between 2 facets 18 | * When 2 segments are matched, one will be the original segment and the other one is removed 19 | * This ensures that all facets share the same segments, but sometimes in reverse order to ensure 20 | * the correct continuity of its entire oborder path 21 | */ 22 | export class FacetBoundarySegment { 23 | constructor(public originalSegment: PathSegment, public neighbour: number, public reverseOrder: boolean) { 24 | 25 | } 26 | } 27 | 28 | export class FacetBorderSegmenter { 29 | /** 30 | * Builds border segments that are shared between facets 31 | * While border paths are all nice and fancy, they are not linked to neighbour facets 32 | * So any change in the paths makes a not so nice gap between the facets, which makes smoothing them out impossible 33 | */ 34 | public static async buildFacetBorderSegments(facetResult: FacetResult, nrOfTimesToHalvePoints: number = 2, onUpdate: ((progress: number) => void) | null = null) { 35 | // first chop up the border path in segments each time the neighbour at that point changes 36 | // (and sometimes even when it doesn't on that side but does on the neighbour's side) 37 | const segmentsPerFacet: Array> = FacetBorderSegmenter.prepareSegmentsPerFacet(facetResult); 38 | // now reduce the segment complexity with Haar wavelet reduction to smooth them out and make them 39 | // more curvy with data points instead of zig zag of a grid 40 | FacetBorderSegmenter.reduceSegmentComplexity(facetResult, segmentsPerFacet, nrOfTimesToHalvePoints); 41 | // now see which segments of facets with the prepared segments of the neighbour facets 42 | // and point them to the same one 43 | await FacetBorderSegmenter.matchSegmentsWithNeighbours(facetResult, segmentsPerFacet, onUpdate); 44 | } 45 | 46 | /** 47 | * Chops up the border paths per facet into segments adjacent tothe same neighbour 48 | */ 49 | private static prepareSegmentsPerFacet(facetResult: FacetResult) { 50 | const segmentsPerFacet: Array> = new Array(facetResult.facets.length); 51 | for (const f of facetResult.facets) { 52 | if (f != null) { 53 | const segments: PathSegment[] = []; 54 | if (f.borderPath.length > 1) { 55 | let currentPoints: PathPoint[] = []; 56 | currentPoints.push(f.borderPath[0]); 57 | for (let i: number = 1; i < f.borderPath.length; i++) { 58 | const prevBorderPoint = f.borderPath[i - 1]; 59 | const curBorderPoint = f.borderPath[i]; 60 | const oldNeighbour = prevBorderPoint.getNeighbour(facetResult); 61 | const curNeighbour = curBorderPoint.getNeighbour(facetResult); 62 | let isTransitionPoint = false; 63 | if (oldNeighbour !== curNeighbour) { 64 | isTransitionPoint = true; 65 | } 66 | else { 67 | // it's possible that due to inner facets inside the current facet that the 68 | // border is interrupted on that facet's side, but not on the neighbour's side 69 | if (oldNeighbour !== -1) { 70 | // check for tight rotations to break path if diagonals contain a different neighbour, 71 | // see https://i.imgur.com/o6Srqwj.png for visual path of the issue 72 | if (prevBorderPoint.x === curBorderPoint.x && 73 | prevBorderPoint.y === curBorderPoint.y) { 74 | // rotation turn 75 | // check the diagonal neighbour to see if it remains the same 76 | // +---+---+ 77 | // | dN| | 78 | // +---xxxx> (x = wall, dN = diagNeighbour) 79 | // | x f | 80 | // +---v---+ 81 | if ((prevBorderPoint.orientation === OrientationEnum.Top && curBorderPoint.orientation === OrientationEnum.Left) || 82 | (prevBorderPoint.orientation === OrientationEnum.Left && curBorderPoint.orientation === OrientationEnum.Top)) { 83 | const diagNeighbour = facetResult.facetMap.get(curBorderPoint.x - 1, curBorderPoint.y - 1); 84 | if (diagNeighbour !== oldNeighbour) { 85 | isTransitionPoint = true; 86 | } 87 | } 88 | else if ((prevBorderPoint.orientation === OrientationEnum.Top && curBorderPoint.orientation === OrientationEnum.Right) || 89 | (prevBorderPoint.orientation === OrientationEnum.Right && curBorderPoint.orientation === OrientationEnum.Top)) { 90 | const diagNeighbour = facetResult.facetMap.get(curBorderPoint.x + 1, curBorderPoint.y - 1); 91 | if (diagNeighbour !== oldNeighbour) { 92 | isTransitionPoint = true; 93 | } 94 | } 95 | else if ((prevBorderPoint.orientation === OrientationEnum.Bottom && curBorderPoint.orientation === OrientationEnum.Left) || 96 | (prevBorderPoint.orientation === OrientationEnum.Left && curBorderPoint.orientation === OrientationEnum.Bottom)) { 97 | const diagNeighbour = facetResult.facetMap.get(curBorderPoint.x - 1, curBorderPoint.y + 1); 98 | if (diagNeighbour !== oldNeighbour) { 99 | isTransitionPoint = true; 100 | } 101 | } 102 | else if ((prevBorderPoint.orientation === OrientationEnum.Bottom && curBorderPoint.orientation === OrientationEnum.Right) || 103 | (prevBorderPoint.orientation === OrientationEnum.Right && curBorderPoint.orientation === OrientationEnum.Bottom)) { 104 | const diagNeighbour = facetResult.facetMap.get(curBorderPoint.x + 1, curBorderPoint.y + 1); 105 | if (diagNeighbour !== oldNeighbour) { 106 | isTransitionPoint = true; 107 | } 108 | } 109 | } 110 | } 111 | } 112 | currentPoints.push(curBorderPoint); 113 | if (isTransitionPoint) { 114 | // aha! a transition point, create the current points as new segment 115 | // and start a new list 116 | if (currentPoints.length > 1) { 117 | const segment = new PathSegment(currentPoints, oldNeighbour); 118 | segments.push(segment); 119 | currentPoints = [curBorderPoint]; 120 | } 121 | } 122 | } 123 | // finally check if there is a remainder partial segment and either prepend 124 | // the points to the first segment if they have the same neighbour or construct a 125 | // new segment 126 | if (currentPoints.length > 1) { 127 | const oldNeighbour = f.borderPath[f.borderPath.length - 1].getNeighbour(facetResult); 128 | if (segments.length > 0 && segments[0].neighbour === oldNeighbour) { 129 | // the first segment and the remainder of the last one are the same part 130 | // add the current points to the first segment by prefixing it 131 | const mergedPoints = currentPoints.concat(segments[0].points); 132 | segments[0].points = mergedPoints; 133 | } 134 | else { 135 | // add the remainder as final segment 136 | const segment = new PathSegment(currentPoints, oldNeighbour); 137 | segments.push(segment); 138 | currentPoints = []; 139 | } 140 | } 141 | } 142 | segmentsPerFacet[f.id] = segments; 143 | } 144 | } 145 | return segmentsPerFacet; 146 | } 147 | 148 | /** 149 | * Reduces each segment border path points 150 | */ 151 | private static reduceSegmentComplexity(facetResult: FacetResult, segmentsPerFacet: Array>, nrOfTimesToHalvePoints: number) { 152 | for (const f of facetResult.facets) { 153 | if (f != null) { 154 | for (const segment of segmentsPerFacet[f.id]) { 155 | for (let i: number = 0; i < nrOfTimesToHalvePoints; i++) { 156 | segment!.points = FacetBorderSegmenter.reduceSegmentHaarWavelet(segment!.points, true, facetResult.width, facetResult.height); 157 | } 158 | } 159 | } 160 | } 161 | } 162 | 163 | /** 164 | * Remove the points by taking the average per pair and using that as a new point 165 | * in the reduced segment. The delta values that create the Haar wavelet are not tracked 166 | * because they are unneeded. 167 | */ 168 | private static reduceSegmentHaarWavelet(newpath: PathPoint[], skipOutsideBorders: boolean, width: number, height: number) { 169 | if (newpath.length <= 5) { 170 | return newpath; 171 | } 172 | const reducedPath: PathPoint[] = []; 173 | reducedPath.push(newpath[0]); 174 | for (let i: number = 1; i < newpath.length - 2; i += 2) { 175 | if (!skipOutsideBorders || (skipOutsideBorders && !FacetBorderSegmenter.isOutsideBorderPoint(newpath[i], width, height))) { 176 | const cx = (newpath[i].x + newpath[i + 1].x) / 2; 177 | const cy = (newpath[i].y + newpath[i + 1].y) / 2; 178 | reducedPath.push(new PathPoint(new Point(cx, cy), OrientationEnum.Left)); 179 | } 180 | else { 181 | reducedPath.push(newpath[i]); 182 | reducedPath.push(newpath[i + 1]); 183 | } 184 | } 185 | // close the loop 186 | reducedPath.push(newpath[newpath.length - 1]); 187 | return reducedPath; 188 | } 189 | 190 | private static isOutsideBorderPoint(point: Point, width: number, height: number) { 191 | return point.x === 0 || point.y === 0 || point.x === width - 1 || point.y === height - 1; 192 | } 193 | 194 | private static calculateArea(path: Point[]) { 195 | let total = 0; 196 | for (let i = 0; i < path.length; i++) { 197 | const addX = path[i].x; 198 | const addY = path[i === path.length - 1 ? 0 : i + 1].y; 199 | const subX = path[i === path.length - 1 ? 0 : i + 1].x; 200 | const subY = path[i].y; 201 | total += (addX * addY * 0.5); 202 | total -= (subX * subY * 0.5); 203 | } 204 | return Math.abs(total); 205 | } 206 | /** 207 | * Matches all segments with each other between facets and their neighbour 208 | * A segment matches when the start and end match or the start matches with the end and vice versa 209 | * (then the segment will need to be traversed in reverse order) 210 | */ 211 | private static async matchSegmentsWithNeighbours(facetResult: FacetResult, segmentsPerFacet: Array>, onUpdate: ((progress: number) => void) | null = null) { 212 | // max distance of the start/end points of the segment that it can be before the segments don't match up 213 | const MAX_DISTANCE = 4; 214 | // reserve room 215 | for (const f of facetResult.facets) { 216 | if (f != null) { 217 | f.borderSegments = new Array(segmentsPerFacet[f.id].length); 218 | } 219 | } 220 | let count = 0; 221 | // and now the fun begins to match segments from 1 facet to its neighbours and vice versa 222 | for (const f of facetResult.facets) { 223 | if (f != null) { 224 | const debug = false; 225 | for (let s: number = 0; s < segmentsPerFacet[f.id].length; s++) { 226 | const segment = segmentsPerFacet[f.id][s]; 227 | if (segment != null && f.borderSegments[s] == null) { 228 | f.borderSegments[s] = new FacetBoundarySegment(segment, segment.neighbour, false); 229 | if (debug) { 230 | console.log("Setting facet " + f.id + " segment " + s + " to " + f.borderSegments[s]); 231 | } 232 | if (segment.neighbour !== -1) { 233 | const neighbourFacet = facetResult.facets[segment.neighbour]; 234 | // see if there is a match to be found 235 | let matchFound = false; 236 | if (neighbourFacet != null) { 237 | const neighbourSegments = segmentsPerFacet[segment.neighbour]; 238 | for (let ns: number = 0; ns < neighbourSegments.length; ns++) { 239 | const neighbourSegment = neighbourSegments[ns]; 240 | // only try to match against the segments that aren't processed yet 241 | // and which are adjacent to the boundary of the current facet 242 | if (neighbourSegment != null && neighbourSegment.neighbour === f.id) { 243 | const segStartPoint = segment.points[0]; 244 | const segEndPoint = segment.points[segment.points.length - 1]; 245 | const nSegStartPoint = neighbourSegment.points[0]; 246 | const nSegEndPoint = neighbourSegment.points[neighbourSegment.points.length - 1]; 247 | let matchesStraight = (segStartPoint.distanceTo(nSegStartPoint) <= MAX_DISTANCE && 248 | segEndPoint.distanceTo(nSegEndPoint) <= MAX_DISTANCE); 249 | let matchesReverse = (segStartPoint.distanceTo(nSegEndPoint) <= MAX_DISTANCE && 250 | segEndPoint.distanceTo(nSegStartPoint) <= MAX_DISTANCE); 251 | if (matchesStraight && matchesReverse) { 252 | // dang it , both match, it must be a tiny segment, but when placed wrongly it'll overlap in the path creating an hourglass 253 | // e.g. https://i.imgur.com/XZQhxRV.png 254 | // determine which is the closest 255 | if (segStartPoint.distanceTo(nSegStartPoint) + segEndPoint.distanceTo(nSegEndPoint) < 256 | segStartPoint.distanceTo(nSegEndPoint) + segEndPoint.distanceTo(nSegStartPoint)) { 257 | matchesStraight = true; 258 | matchesReverse = false; 259 | } 260 | else { 261 | matchesStraight = false; 262 | matchesReverse = true; 263 | } 264 | } 265 | if (matchesStraight) { 266 | // start & end points match 267 | if (debug) { 268 | console.log("Match found for facet " + f.id + " to neighbour " + neighbourFacet.id); 269 | } 270 | neighbourFacet!.borderSegments[ns] = new FacetBoundarySegment(segment, f.id, false); 271 | if (debug) { 272 | console.log("Setting facet " + neighbourFacet!.id + " segment " + ns + " to " + neighbourFacet!.borderSegments[ns]); 273 | } 274 | segmentsPerFacet[neighbourFacet.id][ns] = null; 275 | matchFound = true; 276 | break; 277 | } 278 | else if (matchesReverse) { 279 | // start & end points match but in reverse order 280 | if (debug) { 281 | console.log("Reverse match found for facet " + f.id + " to neighbour " + neighbourFacet.id); 282 | } 283 | neighbourFacet!.borderSegments[ns] = new FacetBoundarySegment(segment, f.id, true); 284 | if (debug) { 285 | console.log("Setting facet " + neighbourFacet!.id + " segment " + ns + " to " + neighbourFacet!.borderSegments[ns]); 286 | } 287 | segmentsPerFacet[neighbourFacet.id][ns] = null; 288 | matchFound = true; 289 | break; 290 | } 291 | } 292 | } 293 | } 294 | if (!matchFound && debug) { 295 | // it's possible that the border is not shared with its neighbour 296 | // this can happen when the segment fully falls inside the other facet 297 | // though the above checks in the preparation of the segments should probably 298 | // cover all cases 299 | console.error("No match found for segment of " + f.id + ": " + 300 | ("siding " + segment.neighbour + " " + segment.points[0] + " -> " + segment.points[segment.points.length - 1])); 301 | } 302 | } 303 | } 304 | // clear the current segment so it can't be processed again when processing the neighbour facet 305 | segmentsPerFacet[f.id][s] = null; 306 | } 307 | if (count % 100 === 0) { 308 | await delay(0); 309 | if (onUpdate != null) { 310 | onUpdate(f.id / facetResult.facets.length); 311 | } 312 | } 313 | } 314 | count++; 315 | } 316 | if (onUpdate != null) { 317 | onUpdate(1); 318 | } 319 | } 320 | } 321 | --------------------------------------------------------------------------------