Skip to content
This repository has been archived by the owner on Oct 29, 2020. It is now read-only.

Commit

Permalink
refactor: replace canvas with sharp
Browse files Browse the repository at this point in the history
canvas does not support being loaded in worker threads due to it being a native dependency that was not written using a thread-compatible API. sharp, on the other hand, is thread-aware: lovell/sharp#1558.
  • Loading branch information
eventualbuddha committed Sep 29, 2020
1 parent e4b8503 commit f3e1bf9
Show file tree
Hide file tree
Showing 13 changed files with 328 additions and 230 deletions.
3 changes: 2 additions & 1 deletion package.json
Expand Up @@ -42,12 +42,12 @@
},
"dependencies": {
"@votingworks/ballot-encoder": "^4.0.0",
"canvas": "^2.6.1",
"chalk": "^4.0.0",
"debug": "^4.1.1",
"jsfeat": "^0.0.8",
"jsqr": "^1.3.1",
"node-quirc": "^2.2.1",
"sharp": "^0.26.1",
"table": "^5.4.6",
"uuid": "^7.0.3"
},
Expand All @@ -58,6 +58,7 @@
"@types/jsfeat": "./types/jsfeat",
"@types/memorystream": "^0.3.0",
"@types/node": "^13.11.1",
"@types/sharp": "^0.26.0",
"@types/table": "^5.0.0",
"@types/uuid": "^7.0.2",
"@typescript-eslint/eslint-plugin": "^4.1.0",
Expand Down
48 changes: 24 additions & 24 deletions src/Interpreter.test.ts
@@ -1,3 +1,6 @@
import { BallotType } from '@votingworks/ballot-encoder'
import * as choctaw2020Special from '../test/fixtures/choctaw-2020-09-22-f30480cc99'
import * as choctawMock2020 from '../test/fixtures/choctaw-county-mock-general-election-choctaw-2020-e87f23ca2c'
import {
blankPage1,
blankPage2,
Expand All @@ -7,13 +10,10 @@ import {
partialBorderPage2,
} from '../test/fixtures/election-4e31cb17d8-ballot-style-77-precinct-oaklawn-branch-library'
import * as hamilton from '../test/fixtures/election-5c6e578acf-state-of-hamilton-2020'
import * as choctaw2019 from '../test/fixtures/election-98f5203139-choctaw-general-2019'
import * as choctaw2020 from '../test/fixtures/election-7c61368c3b-choctaw-general-2020'
import * as choctawMock2020 from '../test/fixtures/choctaw-county-mock-general-election-choctaw-2020-e87f23ca2c'
import * as choctaw2020Special from '../test/fixtures/choctaw-2020-09-22-f30480cc99'
import * as choctaw2019 from '../test/fixtures/election-98f5203139-choctaw-general-2019'
import Interpreter from './Interpreter'
import { DetectQRCodeResult, BallotTargetMark } from './types'
import { BallotType } from '@votingworks/ballot-encoder'
import { BallotTargetMark, DetectQRCodeResult } from './types'

test('interpret three-column template with instructions', async () => {
const interpreter = new Interpreter(election)
Expand Down Expand Up @@ -1614,7 +1614,7 @@ test('interpret votes', async () => {
},
Object {
"option": "Jane Bland",
"score": 0.5717884130982368,
"score": 0.5714285714285714,
"type": "candidate",
},
Object {
Expand All @@ -1629,12 +1629,12 @@ test('interpret votes', async () => {
},
Object {
"option": "Write-In",
"score": 0.66,
"score": 0.6567164179104478,
"type": "candidate",
},
Object {
"option": "John Ames",
"score": 0.7860824742268041,
"score": 0.7866323907455013,
"type": "candidate",
},
Object {
Expand All @@ -1649,7 +1649,7 @@ test('interpret votes', async () => {
},
Object {
"option": "Chad Prda",
"score": 0.601010101010101,
"score": 0.5989974937343359,
"type": "candidate",
},
Object {
Expand Down Expand Up @@ -1985,7 +1985,7 @@ test('invalid marks', async () => {
"type": "yesno",
},
"option": "yes",
"score": 0.14910025706940874,
"score": 0.1483375959079284,
"target": Object {
"bounds": Object {
"height": 21,
Expand Down Expand Up @@ -2037,10 +2037,10 @@ test('invalid marks', async () => {
},
Object {
"bounds": Object {
"height": 22,
"height": 21,
"width": 32,
"x": 470,
"y": 315,
"y": 316,
},
"contest": Object {
"description": "Shall the Dallas County extend the Recycling Program countywide?",
Expand All @@ -2054,10 +2054,10 @@ test('invalid marks', async () => {
"score": 0,
"target": Object {
"bounds": Object {
"height": 22,
"height": 21,
"width": 32,
"x": 470,
"y": 315,
"y": 316,
},
"inner": Object {
"height": 18,
Expand All @@ -2070,10 +2070,10 @@ test('invalid marks', async () => {
},
Object {
"bounds": Object {
"height": 22,
"height": 21,
"width": 32,
"x": 470,
"y": 365,
"y": 366,
},
"contest": Object {
"description": "Shall the Dallas County extend the Recycling Program countywide?",
Expand All @@ -2087,10 +2087,10 @@ test('invalid marks', async () => {
"score": 0.7455470737913485,
"target": Object {
"bounds": Object {
"height": 22,
"height": 21,
"width": 32,
"x": 470,
"y": 365,
"y": 366,
},
"inner": Object {
"height": 18,
Expand Down Expand Up @@ -2523,7 +2523,7 @@ test('invalid marks', async () => {
},
Object {
"bounds": Object {
"height": 22,
"height": 21,
"width": 32,
"x": 470,
"y": 1037,
Expand Down Expand Up @@ -2577,7 +2577,7 @@ test('invalid marks', async () => {
"score": 0,
"target": Object {
"bounds": Object {
"height": 22,
"height": 21,
"width": 32,
"x": 470,
"y": 1037,
Expand Down Expand Up @@ -2663,7 +2663,7 @@ test('invalid marks', async () => {
},
Object {
"bounds": Object {
"height": 22,
"height": 21,
"width": 32,
"x": 470,
"y": 1137,
Expand Down Expand Up @@ -2717,7 +2717,7 @@ test('invalid marks', async () => {
"score": 0,
"target": Object {
"bounds": Object {
"height": 22,
"height": 21,
"width": 32,
"x": 470,
"y": 1137,
Expand Down Expand Up @@ -2873,10 +2873,10 @@ test('invalid marks', async () => {
"y": 333,
},
"inner": Object {
"height": 17,
"height": 18,
"width": 28,
"x": 874,
"y": 335,
"y": 334,
},
},
"type": "candidate",
Expand Down
2 changes: 1 addition & 1 deletion src/utils/binarize.test.ts
@@ -1,6 +1,6 @@
import { createImageData } from 'canvas'
import { croppedQRCode } from '../../test/fixtures'
import { binarize, RGBA_BLACK, RGBA_WHITE } from './binarize'
import { createImageData } from './canvas'

test('binarize grayscale', async () => {
const imageData = createImageData(2, 2)
Expand Down
33 changes: 33 additions & 0 deletions src/utils/canvas.ts
@@ -0,0 +1,33 @@
const RGBA_CHANNEL_COUNT = 4

export function createImageData(
data: Uint8ClampedArray,
width: number,
height: number
): ImageData
export function createImageData(width: number, height: number): ImageData
export function createImageData(...args: unknown[]): ImageData {
let data: Uint8ClampedArray
let width: number
let height: number

if (
args.length === 2 &&
typeof args[0] === 'number' &&
typeof args[1] === 'number'
) {
;[width, height] = args
data = new Uint8ClampedArray(width * height * RGBA_CHANNEL_COUNT)
} else if (
args.length === 3 &&
args[0] instanceof Uint8ClampedArray &&
typeof args[1] === 'number' &&
typeof args[2] === 'number'
) {
;[data, width, height] = args
} else {
throw new TypeError('unexpected arguments given to createImageData')
}

return { data, width, height }
}
2 changes: 1 addition & 1 deletion src/utils/crop.test.ts
@@ -1,6 +1,6 @@
import { createImageData } from 'canvas'
import { randomImage, randomInset } from '../../test/utils'
import { Rect } from '../types'
import { createImageData } from './canvas'
import crop from './crop'

/**
Expand Down
2 changes: 1 addition & 1 deletion src/utils/crop.ts
@@ -1,5 +1,5 @@
import { createImageData } from 'canvas'
import { Rect } from '../types'
import { createImageData } from './canvas'

/**
* Returns a new image cropped to the specified bounds.
Expand Down
2 changes: 1 addition & 1 deletion src/utils/flip.test.ts
@@ -1,4 +1,4 @@
import { createImageData } from 'canvas'
import { createImageData } from './canvas'
import { vh } from './flip'

test('vh does nothing to 1x1 image (rgba)', () => {
Expand Down
6 changes: 2 additions & 4 deletions src/utils/jsfeat/matToImageData.ts
@@ -1,10 +1,8 @@
import { createCanvas } from 'canvas'
import * as jsfeat from 'jsfeat'
import { createImageData } from '../canvas'

export default function matToImageData(mat: jsfeat.matrix_t): ImageData {
const canvas = createCanvas(mat.cols, mat.rows)
const ctx = canvas.getContext('2d')
const imageData = ctx.getImageData(0, 0, mat.cols, mat.rows)
const imageData = createImageData(mat.cols, mat.rows)
const data_u32 = new Uint32Array(imageData.data.buffer)
const alpha = 0xff << 24
let i = mat.cols * mat.rows
Expand Down
2 changes: 1 addition & 1 deletion src/utils/outline.ts
@@ -1,5 +1,5 @@
import { createImageData } from 'canvas'
import { PIXEL_BLACK } from './binarize'
import { createImageData } from './canvas'
import { getImageChannelCount } from './imageFormatUtils'

/**
Expand Down
31 changes: 14 additions & 17 deletions src/utils/qrcode/quirc.ts
@@ -1,28 +1,25 @@
import { createCanvas } from 'canvas'
import makeDebug from 'debug'
import sharp, { Channels } from 'sharp'
import { DetectQRCodeResult } from '../../types'
import { withCropping } from './withCropping'

const debug = makeDebug('hmpb-interpreter:quirc')

const PNG_DATA_URL_PREFIX = 'data:image/png;base64,'

/**
* Encodes an image as a PNG.
*/
function toPNGData(imageData: ImageData): Buffer {
const canvas = createCanvas(imageData.width, imageData.height)
const context = canvas.getContext('2d')

context.putImageData(imageData, 0, 0)

const dataURL = canvas.toDataURL('image/png')

if (!dataURL.startsWith(PNG_DATA_URL_PREFIX)) {
throw new Error(`PNG data URL has unexpected format: ${dataURL}`)
}

return Buffer.from(dataURL.slice(PNG_DATA_URL_PREFIX.length), 'base64')
async function toPNGData(imageData: ImageData): Promise<Buffer> {
return await sharp(Buffer.from(imageData.data.buffer), {
raw: {
width: imageData.width,
height: imageData.height,
channels: (imageData.data.length /
imageData.width /
imageData.height) as Channels,
},
})
.png()
.toBuffer()
}

/**
Expand All @@ -36,7 +33,7 @@ export async function detect(
// Unfortunately, quirc requires either JPEG or PNG encoded images and can't
// handle raw bitmaps.
const quirc = await import('node-quirc')
const result = await quirc.decode(toPNGData(imageData))
const result = await quirc.decode(await toPNGData(imageData))

for (const symbol of result) {
if (!('err' in symbol)) {
Expand Down
21 changes: 10 additions & 11 deletions src/utils/readImageData.ts
@@ -1,18 +1,17 @@
import { createCanvas, loadImage } from 'canvas'
import { promises as fs } from 'fs'
import sharp from 'sharp'

export async function readImageData(fileData: Buffer): Promise<ImageData>
export async function readImageData(filePath: string): Promise<ImageData>
export async function readImageData(
filePathOrData: string | Buffer
): Promise<ImageData> {
const fileData =
typeof filePathOrData === 'string'
? await fs.readFile(filePathOrData)
: filePathOrData
const image = await loadImage(fileData)
const canvas = createCanvas(image.width, image.height)
const context = canvas.getContext('2d')
context.drawImage(image, 0, 0)
return context.getImageData(0, 0, image.width, image.height)
const img = await sharp(filePathOrData)
.raw()
.ensureAlpha()
.toBuffer({ resolveWithObject: true })
return {
data: Uint8ClampedArray.from(img.data),
width: img.info.width,
height: img.info.height,
}
}

0 comments on commit f3e1bf9

Please sign in to comment.