├── .eslintignore ├── .gitignore ├── example ├── png.d.ts ├── test.png ├── index.html └── main.ts ├── src ├── library.d.ts ├── method-rotate.ts ├── utils.ts ├── method-rotate-by-gl.ts ├── method-contour.ts ├── index.ts ├── gl-outline.ts └── method-distance.ts ├── tsconfig.json ├── .eslintrc.js ├── .github └── workflows │ └── main.yml ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | lib -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | lib -------------------------------------------------------------------------------- /example/png.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png' {} 2 | -------------------------------------------------------------------------------- /example/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liajoy/image-stroke/HEAD/example/test.png -------------------------------------------------------------------------------- /src/library.d.ts: -------------------------------------------------------------------------------- 1 | 2 | declare module 'marching-squares' { 3 | export default function (x: number, y: number, isInside: (x: number, y: number) => boolean): number[] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "outDir": "./lib", 5 | "allowSyntheticDefaultImports": true, 6 | "moduleResolution": "node", 7 | "lib": [ 8 | "dom", 9 | "ESNext" 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | browser: true 5 | }, 6 | extends: [ 7 | 'standard', 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | globals: { 11 | Atomics: 'readonly', 12 | SharedArrayBuffer: 'readonly' 13 | }, 14 | parser: '@typescript-eslint/parser', 15 | parserOptions: { 16 | ecmaVersion: 2018, 17 | sourceType: 'module' 18 | }, 19 | plugins: [ 20 | '@typescript-eslint' 21 | ], 22 | rules: { 23 | "indent": [1, 4], 24 | "@typescript-eslint/explicit-function-return-type": [0] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/method-rotate.ts: -------------------------------------------------------------------------------- 1 | import { StrokeMethod } from './index' 2 | 3 | export default { 4 | context: '2d', 5 | create (ctx, image) { 6 | return (options) => { 7 | ctx.save() 8 | for (let i = 0; i < 360; i++) { 9 | ctx.drawImage( 10 | image, 11 | options.thickness * (1 + Math.cos(i)), 12 | options.thickness * (1 + Math.sin(i)) 13 | ) 14 | } 15 | ctx.globalCompositeOperation = 'source-in' 16 | ctx.fillStyle = options.color 17 | ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height) 18 | ctx.restore() 19 | ctx.drawImage(image, options.thickness, options.thickness) 20 | } 21 | } 22 | } as StrokeMethod<'2d'> 23 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Deploy 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | with: 21 | ref: master 22 | 23 | - name: Install Dependencies 24 | run: npm install 25 | 26 | - name: Build 27 | run: npm run build:example 28 | 29 | - name: Commit files 30 | run: | 31 | git add . 32 | git config --local user.email "liajoy@163.com" 33 | git config --local user.name "liajoy" 34 | git commit -m "build example dist" -a 35 | 36 | - name: Deploy 37 | uses: ad-m/github-push-action@master 38 | with: 39 | branch: gh-pages 40 | force: true 41 | github_token: ${{ secrets.GITHUB_TOKEN }} 42 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | 2 | type Utils< 3 | T extends CanvasRenderingContext2D | WebGLRenderingContext = CanvasRenderingContext2D | WebGLRenderingContext 4 | > = { 5 | clear: (ctx: T) => void; 6 | drawImage: (ctx: T, image: HTMLImageElement, x: number, y: number) => void; 7 | } 8 | 9 | const utilsGl: Utils = { 10 | clear () { 11 | // nothing 12 | }, 13 | drawImage () { 14 | // nothing 15 | } 16 | } 17 | const utils2d: Utils = { 18 | clear (ctx) { 19 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height) 20 | }, 21 | drawImage (ctx, image, x, y) { 22 | ctx.drawImage(image, x, y) 23 | } 24 | } 25 | 26 | export const utils: Utils = { 27 | clear (ctx) { 28 | if (ctx instanceof CanvasRenderingContext2D) { 29 | utils2d.clear(ctx) 30 | } else if (ctx instanceof WebGLRenderingContext) { 31 | utilsGl.clear(ctx) 32 | } 33 | }, 34 | drawImage (ctx, image, x, y) { 35 | if (ctx instanceof CanvasRenderingContext2D) { 36 | utils2d.drawImage(ctx, image, x, y) 37 | } else if (ctx instanceof WebGLRenderingContext) { 38 | utilsGl.drawImage(ctx, image, x, y) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "image-stroke", 3 | "version": "0.1.1", 4 | "description": "Makes stroke for a image with transparent background.", 5 | "main": "./lib/index", 6 | "module": "./lib/index", 7 | "scripts": { 8 | "dev": "parcel example/index.html -d example-dist", 9 | "build": "tsc", 10 | "build:example": "rm -rf public && parcel build example/index.html -d public --public-url public && mv public/index.html ." 11 | }, 12 | "keywords": [ 13 | "canvas", 14 | "outline", 15 | "stroke" 16 | ], 17 | "files": [ 18 | "lib" 19 | ], 20 | "author": "liajoy", 21 | "license": "MIT", 22 | "homepage": "https://github.com/liajoy/image-stroke#readme", 23 | "repository": "https://github.com/liajoy/image-stroke", 24 | "dependencies": { 25 | "color": "^3.1.2", 26 | "marching-squares": "^0.2.0", 27 | "twgl.js": "^4.14.2" 28 | }, 29 | "devDependencies": { 30 | "@types/color": "^3.0.1", 31 | "@typescript-eslint/eslint-plugin": "^2.21.0", 32 | "@typescript-eslint/parser": "^2.21.0", 33 | "cssnano": "^4.1.10", 34 | "eslint": "^6.8.0", 35 | "eslint-config-standard": "^14.1.0", 36 | "eslint-plugin-import": "^2.20.1", 37 | "eslint-plugin-node": "^11.0.0", 38 | "eslint-plugin-promise": "^4.2.1", 39 | "eslint-plugin-standard": "^4.0.1", 40 | "parcel": "^1.12.4", 41 | "typescript": "^3.1.4" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ImageStroke 2 | 3 | Makes stroke for a image with transparent background. 4 | 5 | ## Demo 6 | 7 | [Click here to see live demo.](https://liajoy.github.io/image-stroke/) 8 | 9 | ## Getting Started 10 | 11 | ### Installing 12 | 13 | ``` bash 14 | yarn add image-stroke 15 | ``` 16 | 17 | ### How To Use 18 | 19 | There are three built-in methods to make stroke. Choose one of them in different situations. 20 | 21 | - Rotate. Rotate the image with 360 degree. 22 | - Contour. Detects contours of the image by [Marching Squares](https://en.wikipedia.org/wiki/Marching_squares) and get the paths, then stroke those paths. 23 | - Distance. Calculates the distance from each pixel to the edge of the image by [Distance transform](https://en.wikipedia.org/wiki/Distance_transform), and fills all pixels whose distance is less than the stroke width. 24 | - Rotate by WebGL. The same as 「Rotate」 but implements in WebGL. 25 | 26 | ``` javascript 27 | import ImageStroke from 'image-stroke' 28 | 29 | // Choose one of these methods 30 | import rotate from 'image-stroke/lib/method-rotate' 31 | import distance from 'image-stroke/lib/method-distance' 32 | import contour from 'image-stroke/lib/method-contour' 33 | import rotateByGL from 'image-stroke/lib/method-rotate-by-gl' 34 | 35 | const imageStroke = new ImageStroke() 36 | 37 | // Just use it 38 | imageStroke.use(rotate) 39 | 40 | const image = new Image(); 41 | image.onload = () => { 42 | // Get result 43 | const resultCanvas = imageStroke.make(image, { 44 | thickness: 10, 45 | color: 'red' 46 | }) 47 | } 48 | ``` -------------------------------------------------------------------------------- /src/method-rotate-by-gl.ts: -------------------------------------------------------------------------------- 1 | import { StrokeMethod } from './index' 2 | import { createOutlineProgram } from './gl-outline' 3 | 4 | const fs = ` 5 | precision mediump float; 6 | varying vec2 uv; 7 | uniform sampler2D texture; 8 | uniform vec3 color; 9 | uniform vec2 thickness; 10 | 11 | void main() { 12 | vec4 texColor = texture2D(texture, uv); 13 | 14 | vec2 sibling; 15 | float curAlpha; 16 | float maxAlpha = 0.0; 17 | float minAlpha = 1.0; 18 | 19 | for (float angle = 0.0; angle <= 360.0; angle += 1.0) { 20 | if(texColor.a > 0.5 || maxAlpha == 1.0) break; 21 | sibling.x = uv.x + thickness.x * cos(angle); 22 | sibling.y = uv.y + thickness.y * sin(angle); 23 | curAlpha = texture2D(texture, sibling).a; 24 | minAlpha = min(minAlpha, curAlpha); 25 | maxAlpha = max(maxAlpha, curAlpha); 26 | } 27 | float alpha = max(maxAlpha, texColor.a); 28 | 29 | // If current color isn't opaque, need plus outline color as background. 30 | vec3 background = texColor.a == 0. ? color * texColor.a : vec3(0.); 31 | vec3 outlineColor = maxAlpha * color; 32 | gl_FragColor = vec4(texColor.rgb * texColor.a + background + outlineColor, alpha); 33 | } 34 | ` 35 | 36 | export default { 37 | context: 'gl', 38 | create (ctx, image) { 39 | const glProgram = createOutlineProgram(ctx, image, fs) 40 | 41 | return options => { 42 | glProgram.update(options) 43 | } 44 | } 45 | } as StrokeMethod<'gl'> 46 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 29 | 30 | 31 |
32 | 41 | 42 | 46 | 47 | 51 | 52 | 56 | 57 | stroke time 58 |
59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /src/method-contour.ts: -------------------------------------------------------------------------------- 1 | import { StrokeMethod } from './index' 2 | import traceRegion from 'marching-squares' 3 | 4 | export function getContours (ctx: CanvasRenderingContext2D, opacityThreshold = 100) { 5 | const { width, height } = ctx.canvas 6 | const { data } = ctx.getImageData(0, 0, width, height) 7 | const isInside = (x: number, y: number) => { 8 | return x >= 0 && y >= 0 && x < width && y < height 9 | ? data[(y * width + x) * 4 - 1] > opacityThreshold 10 | : false 11 | } 12 | 13 | let contours = [] 14 | let startPos = -1 15 | for (let i = 3; i < data.length; i += 4) { 16 | if (data[i] > opacityThreshold) { 17 | startPos = (i + 1) / 4 18 | break 19 | } 20 | } 21 | 22 | if (startPos >= 0) { 23 | contours = traceRegion( 24 | startPos % width, 25 | Math.floor(startPos / width), 26 | isInside 27 | ) 28 | } 29 | 30 | return contours 31 | } 32 | 33 | const canvas4Image = document.createElement('canvas') 34 | const ctx4Image = canvas4Image.getContext('2d') 35 | export default { 36 | context: '2d', 37 | create (ctx, image) { 38 | return (options) => { 39 | ctx.save() 40 | canvas4Image.width = image.width 41 | canvas4Image.height = image.height 42 | ctx4Image.drawImage(image, 0, 0) 43 | 44 | const contours = getContours(ctx4Image) 45 | const x = options.thickness 46 | const y = options.thickness 47 | ctx.strokeStyle = options.color 48 | ctx.lineWidth = options.thickness * 2 49 | ctx.lineJoin = 'round' 50 | 51 | ctx.beginPath() 52 | ctx.moveTo(x + contours[0].x, y + contours[1].y) 53 | for (let i = 1; i < contours.length; i++) { 54 | ctx.lineTo(x + contours[i].x, y + contours[i].y) 55 | } 56 | ctx.closePath() 57 | ctx.stroke() 58 | ctx.restore() 59 | ctx.drawImage(image, options.thickness, options.thickness) 60 | } 61 | } 62 | } as StrokeMethod<'2d'> 63 | -------------------------------------------------------------------------------- /example/main.ts: -------------------------------------------------------------------------------- 1 | import imageUrl from './test.png' 2 | import ImageStroke from '../src/index' 3 | import methodRotate from '../src/method-rotate' 4 | import methodContour from '../src/method-contour' 5 | import methodDistance from '../src/method-distance' 6 | import methodRotateGl from '../src/method-rotate-by-gl' 7 | 8 | const $canvas = document.getElementById('canvas') as HTMLCanvasElement 9 | const $select = document.getElementById('select') as HTMLSelectElement 10 | const $thickness = document.getElementById('thickness') as HTMLInputElement 11 | const $color = document.getElementById('color') as HTMLInputElement 12 | const $file = document.getElementById('file') as HTMLInputElement 13 | const $time = document.getElementById('time') 14 | 15 | const showPerf = () => { 16 | const startTime = performance.now() 17 | return () => { 18 | $time.innerText = Math.round(performance.now() - startTime) + 'ms' 19 | } 20 | } 21 | 22 | let targetImage 23 | const imageStroke = new ImageStroke(methodRotate) 24 | const methodMap = { 25 | rotate: methodRotate, 26 | contour: methodContour, 27 | distance: methodDistance, 28 | rotateByGl: methodRotateGl 29 | } 30 | const update = () => { 31 | const endPerf = showPerf() 32 | imageStroke.use(methodMap[$select.value]) 33 | const result = imageStroke.make(targetImage, { 34 | thickness: Number($thickness.value), 35 | color: $color.value 36 | }) 37 | const ctx = $canvas.getContext('2d') 38 | $canvas.width = result.width 39 | $canvas.height = result.height 40 | $canvas.style.width = result.width + 'px' 41 | $canvas.style.height = result.height + 'px' 42 | ctx.drawImage(result, 0, 0) 43 | endPerf() 44 | } 45 | 46 | const useImage = url => { 47 | const image = new Image() 48 | image.onload = () => { 49 | targetImage = image 50 | update() 51 | } 52 | image.src = url 53 | } 54 | 55 | useImage(imageUrl) 56 | 57 | $select.addEventListener('input', update) 58 | $thickness.addEventListener('input', update) 59 | $color.addEventListener('input', update) 60 | $file.addEventListener('change', (e) => { 61 | const file = (e.target as HTMLInputElement).files[0] 62 | useImage(URL.createObjectURL(file)) 63 | }) 64 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { utils } from './utils' 2 | 3 | export type Options = { 4 | thickness: number; 5 | color: string; 6 | } 7 | export type StrokeMethod< 8 | T extends 'gl' | '2d' = 'gl' | '2d', 9 | > = { 10 | context: T; 11 | create: ( 12 | context: T extends 'gl' ? WebGLRenderingContext : CanvasRenderingContext2D, 13 | image: HTMLImageElement 14 | ) => (options: Options) => void; 15 | } 16 | 17 | const createCanvas = () => document.createElement('canvas') 18 | 19 | export default class ImageStroke { 20 | canvas: HTMLCanvasElement | null = null 21 | 22 | glCanvas: HTMLCanvasElement | null = null 23 | 24 | image: HTMLImageElement 25 | 26 | method: ReturnType 27 | 28 | useMethod: StrokeMethod 29 | 30 | constructor (useMethod?: StrokeMethod) { 31 | this.useMethod = useMethod 32 | } 33 | 34 | getCanvas (useMethod = this.useMethod) { 35 | if (useMethod.context === 'gl') { 36 | this.glCanvas = this.glCanvas || createCanvas() 37 | return this.glCanvas 38 | } else { 39 | this.canvas = this.canvas || createCanvas() 40 | return this.canvas 41 | } 42 | } 43 | 44 | initMethod (image = this.image, useMethod = this.useMethod) { 45 | if (this.image !== image || this.useMethod !== useMethod) { 46 | this.method = useMethod.create(this.getContext(useMethod), image) 47 | } 48 | } 49 | 50 | setImage (image: HTMLImageElement) { 51 | this.initMethod(image) 52 | } 53 | 54 | use (useMethod: StrokeMethod) { 55 | this.initMethod(undefined, useMethod) 56 | this.useMethod = useMethod 57 | } 58 | 59 | make (image: HTMLImageElement, options: Options) { 60 | this.initMethod(image) 61 | this.image = image 62 | 63 | const canvas = this.getCanvas() 64 | const strokeSize = options.thickness * 2 65 | const [ 66 | resultWidth, 67 | resultHeight 68 | ] = [this.image.width, this.image.height].map((val) => val + strokeSize) 69 | const context = this.getContext() 70 | 71 | if (resultWidth !== canvas.width || resultHeight !== canvas.height) { 72 | canvas.width = resultWidth 73 | canvas.height = resultHeight 74 | } 75 | 76 | utils.clear(context) 77 | 78 | this.method(options) 79 | 80 | return canvas 81 | } 82 | 83 | private getContext (useMethod = this.useMethod) { 84 | const canvas = this.getCanvas(useMethod) 85 | switch (useMethod.context) { 86 | case 'gl': 87 | return canvas.getContext('webgl') as WebGLRenderingContext 88 | case '2d': 89 | return canvas.getContext('2d') as CanvasRenderingContext2D 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/gl-outline.ts: -------------------------------------------------------------------------------- 1 | import * as twgl from 'twgl.js' 2 | import Color from 'color' 3 | import { Options } from './index' 4 | 5 | const vs = ` 6 | precision mediump float; 7 | attribute vec2 position; 8 | varying vec2 uv; 9 | void main() { 10 | gl_Position = vec4(position, 0.0, 1.0); 11 | uv = position / 2.0 + 0.5; 12 | } 13 | ` 14 | const fs = ` 15 | precision mediump float; 16 | varying vec2 uv; 17 | uniform sampler2D texture; 18 | uniform vec2 offset; 19 | 20 | void main() { 21 | vec2 newUv = uv; 22 | // clip 23 | newUv *= step(offset, newUv); 24 | newUv *= step(offset, 1.0 - newUv); 25 | // scale 26 | newUv -= offset; 27 | newUv /= 1.0 - 2.0 * offset; 28 | 29 | if (newUv.x < 0. || newUv.y < 0.) { 30 | discard; 31 | } 32 | 33 | gl_FragColor = texture2D(texture, newUv); 34 | } 35 | ` 36 | 37 | export function createOutlineProgram (gl: WebGLRenderingContext, image: HTMLImageElement, outlineFs: string) { 38 | const imageProgram = twgl.createProgramInfo(gl, [vs, fs]) 39 | const arrays = { 40 | position: [-1, -1, 0, 1, -1, 0, 1, 1, 0, 1, 1, 0, -1, -1, 0, -1, 1, 0] 41 | } 42 | const bufferInfo = twgl.createBufferInfoFromArrays(gl, arrays) 43 | const outlineProgram = twgl.createProgramInfo(gl, [vs, outlineFs]) 44 | const tex = twgl.createTexture(gl, { 45 | src: image, 46 | flipY: 1 47 | }) 48 | 49 | return { 50 | update (options: Options) { 51 | gl.viewport(0, 0, gl.canvas.width, gl.canvas.height) 52 | 53 | const normalizedThickness = [ 54 | options.thickness / gl.canvas.width, 55 | options.thickness / gl.canvas.height 56 | ] 57 | const uniforms = { 58 | texture: tex, 59 | offset: normalizedThickness 60 | } 61 | const fbi = twgl.createFramebufferInfo(gl) 62 | // Transform image to fit canvas 63 | gl.useProgram(imageProgram.program) 64 | twgl.bindFramebufferInfo(gl, fbi) 65 | twgl.setBuffersAndAttributes(gl, imageProgram, bufferInfo) 66 | twgl.setUniforms(imageProgram, uniforms) 67 | twgl.drawBufferInfo(gl, bufferInfo) 68 | twgl.bindFramebufferInfo(gl, null) 69 | 70 | const color = Color(options.color) 71 | const colorArray = color.array().map(val => val / 255) 72 | const outlineUniforms = { 73 | texture: fbi.attachments[0], 74 | thickness: normalizedThickness, 75 | color: colorArray 76 | } 77 | gl.useProgram(outlineProgram.program) 78 | twgl.setBuffersAndAttributes(gl, outlineProgram, bufferInfo) 79 | twgl.setUniforms(outlineProgram, outlineUniforms) 80 | twgl.drawBufferInfo(gl, bufferInfo) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/method-distance.ts: -------------------------------------------------------------------------------- 1 | import { StrokeMethod } from './index' 2 | import Color from 'color' 3 | 4 | // See https://github.com/parmanoir/Meijster-distance 5 | function computeDistances (binaryImage: Uint8Array, width: number, height: number) { 6 | // First phase 7 | const infinity = width + height 8 | const b = binaryImage 9 | const g = new Array(width * height) 10 | for (let x = 0; x < width; x++) { 11 | if (b[x + 0 * width]) { g[x + 0 * width] = 0 } else { 12 | g[x + 0 * width] = infinity 13 | } 14 | // Scan 1 15 | for (let y = 1; y < height; y++) { 16 | if (b[x + y * width]) { g[x + y * width] = 0 } else { 17 | g[x + y * width] = 1 + g[x + (y - 1) * width] 18 | } 19 | } 20 | // Scan 2 21 | for (let y = height - 1; y >= 0; y--) { 22 | if (g[x + (y + 1) * width] < g[x + y * width]) { 23 | g[x + y * width] = 1 + g[x + (y + 1) * width] 24 | } 25 | } 26 | } 27 | 28 | // Euclidean 29 | function EDTFunc (x: number, i: number, gi: number) { 30 | return (x - i) * (x - i) + gi * gi 31 | } 32 | function EDTSep (i: number, u: number, gi: number, gu: number) { 33 | return Math.floor((u * u - i * i + gu * gu - gi * gi) / (2 * (u - i))) 34 | } 35 | 36 | // Second phase 37 | const dt = new Array(width * height) 38 | const s = new Array(width) 39 | const t = new Array(width) 40 | let q = 0 41 | let w = 0 42 | for (let y = 0; y < height; y++) { 43 | q = 0 44 | s[0] = 0 45 | t[0] = 0 46 | 47 | // Scan 3 48 | for (let u = 1; u < width; u++) { 49 | while (q >= 0 && EDTFunc(t[q], s[q], g[s[q] + y * width]) > EDTFunc(t[q], u, g[u + y * width])) { 50 | q-- 51 | } 52 | if (q < 0) { 53 | q = 0 54 | s[0] = u 55 | } else { 56 | w = 1 + EDTSep(s[q], u, g[s[q] + y * width], g[u + y * width]) 57 | if (w < width) { 58 | q++ 59 | s[q] = u 60 | t[q] = w 61 | } 62 | } 63 | } 64 | // Scan 4 65 | for (let u = width - 1; u >= 0; u--) { 66 | let d = EDTFunc(u, s[q], g[s[q] + y * width]) 67 | d = Math.floor(Math.sqrt(d)) 68 | dt[u + y * width] = d 69 | if (u === t[q]) { 70 | q-- 71 | } 72 | } 73 | } 74 | 75 | return dt 76 | } 77 | 78 | const toBinaryImage = (ctx: CanvasRenderingContext2D, threshold = 5) => { 79 | const width = ctx.canvas.width 80 | const height = ctx.canvas.height 81 | const data = ctx.getImageData(0, 0, width, height).data 82 | const binaryImage = new Uint8Array(width * height) 83 | for (let i = 0; i < data.length; i += 4) { 84 | const binary = data[i] < threshold ? 1 : 0 85 | binaryImage[i / 4] = 1 - binary 86 | } 87 | return binaryImage 88 | } 89 | 90 | const canvas4Image = document.createElement('canvas') 91 | const ctx4Image = canvas4Image.getContext('2d') 92 | export default { 93 | context: '2d', 94 | create (ctx, image) { 95 | return (options) => { 96 | const { width, height } = ctx.canvas 97 | const color = Color(options.color) 98 | const colorArray = color.array().concat(color.alpha() * 255) 99 | 100 | canvas4Image.width = width 101 | canvas4Image.height = height 102 | ctx4Image.drawImage(image, options.thickness, options.thickness) 103 | 104 | const binaryImage = toBinaryImage(ctx4Image) 105 | const distances = computeDistances(binaryImage, canvas4Image.width, canvas4Image.height) 106 | const imageData = ctx.getImageData(0, 0, width, height) 107 | const { data } = imageData 108 | for (let i = 0; i < data.length; i += 4) { 109 | const distance = distances[i / 4] 110 | if (distance < options.thickness) { 111 | [ 112 | data[i], 113 | data[i + 1], 114 | data[i + 2], 115 | data[i + 3] 116 | ] = colorArray 117 | } 118 | } 119 | ctx.putImageData(imageData, 0, 0) 120 | ctx.drawImage(image, options.thickness, options.thickness) 121 | } 122 | } 123 | } as StrokeMethod<'2d'> 124 | --------------------------------------------------------------------------------