Last active
July 4, 2020 15:28
-
-
Save uhop/6b0dc8627981ea738264ab6fefb8151a to your computer and use it in GitHub Desktop.
Image-related utilities for use in browsers.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
let flag; | |
const detectWebp = () => { | |
if (typeof flag == 'boolean') return flag; | |
const canvas = document.createElement('canvas'); | |
flag = !!(canvas.getContext && canvas.getContext('2d') && /^data:image\/webp;/.test(canvas.toDataURL('image/webp'))); | |
return flag; | |
}; | |
export default detectWebp; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// helper to copy rectangles | |
class Copier { | |
constructor(srcImageData, dstImageData) { | |
this.srcImageData = srcImageData; | |
this.dstImageData = dstImageData; | |
this.srcLineSize = this.srcImageData.width << 2; | |
this.dstLineSize = this.dstImageData.width << 2; | |
this.sw = this.srcImageData.width; | |
this.sh = this.srcImageData.height; | |
this.dw = this.dstImageData.width; | |
this.dh = this.dstImageData.height; | |
} | |
copy(sx, sy, dx, dy, w, h, step = 1) { | |
const srcOffset = sy * this.srcLineSize + (sx << 2), | |
dstOffset = dy * this.dstLineSize + (dx << 2), | |
count = w << 2, | |
src = this.srcImageData.data, | |
dst = this.dstImageData.data, | |
srcLineSize = step * this.srcLineSize, | |
dstLineSize = this.dstLineSize; | |
for (let j = 0, ps = srcOffset, pd = dstOffset; j < h; ++j, ps += srcLineSize, pd += dstLineSize) { | |
for (let i = 0; i < count; ++i) { | |
dst[pd + i] = src[ps + i]; | |
} | |
} | |
} | |
} | |
export const extendImageData = (srcImageData, dstImageData, dx, dy, style) => { | |
if (dx < 0 || dy < 0) throw Error('Negative offsets'); | |
if (srcImageData.width > dstImageData.width || srcImageData.height > dstImageData.height) throw Error('Source image is bigger than destination'); | |
if (srcImageData.width + dx > dstImageData.width || srcImageData.height + dy > dstImageData.height) | |
throw Error('Source image with offset is outside of destination'); | |
const copier = new Copier(srcImageData, dstImageData), | |
sw = copier.sw, | |
sh = copier.sh, | |
dw = copier.dw, | |
dh = copier.dh; | |
// copy the image | |
copier.copy(0, 0, dx, dy, sw, sh); | |
if (style === 'extend') { | |
// extend top and bottom | |
copier.copy(0, 0, dx, 0, sw, dy, 0); | |
copier.copy(0, sh - 1, dx, dy + sh, sw, dh - sh - dy, 0); | |
// extend left and right | |
for (let i = 0; i < dx; ++i) { | |
copier.copy(0, 0, i, dy, 1, sh); | |
copier.copy(sw - 1, 0, dx + sw + i, dy, 1, sh); | |
} | |
// extend corners | |
for (let i = 0; i < dx; ++i) { | |
copier.copy(0, 0, i, 0, 1, dy, 0); // TL | |
copier.copy(0, sh - 1, i, dy + sh, 1, dh - sh - dy, 0); // BL | |
copier.copy(sw - 1, 0, dx + sw + i, 0, 1, dy, 0); // TR | |
copier.copy(sw - 1, sh - 1, dx + sw + i, dy + sh, 1, dh - sh - dy, 0); // TL | |
} | |
return dstImageData; | |
} | |
if ( | |
dx > srcImageData.width || | |
dy > srcImageData.height || | |
dstImageData.width - srcImageData.width - dx > srcImageData.width || | |
dstImageData.height - srcImageData.height - dy > srcImageData.height | |
) | |
throw Error('Extension margins are bigger than the source image'); | |
if (style === 'wrap') { | |
// extend top and bottom | |
copier.copy(0, sh - dy, dx, 0, sw, dy); | |
copier.copy(0, 0, dx, dy + sh, sw, dh - sh - dy); | |
// extend left and right | |
copier.copy(sw - dx, 0, 0, dy, dx, sh); | |
copier.copy(0, 0, dx + sw, dy, dw - sw - dx, sh); | |
// extend corners | |
copier.copy(sw - dx, sh - dy, 0, 0, dx, dy); // TL | |
copier.copy(0, 0, dx + sw, dy + sh, dw - sw - dx, dh - sh, dy); // BR | |
copier.copy(0, sh - dy, dx + sw, 0, dw - sw - dx, dy); // TR | |
copier.copy(sw - dx, 0, 0, dy + sh, dx, dh - sh - dy); // BL | |
return dstImageData; | |
} | |
if (style === 'mirror') { | |
// extend top and bottom | |
copier.copy(0, dy - 1, dx, 0, sw, dy, -1); | |
copier.copy(0, sh - 1, dx, dy + sh, sw, dh - sh - dy, -1); | |
// extend left and right | |
for (let i = 0; i < dx; ++i) { | |
copier.copy(i, 0, dx - i - 1, dy, 1, sh); | |
copier.copy(sw - i - 1, 0, dx + sw + i, dy, 1, sh); | |
} | |
// extend corners | |
for (let i = 0; i < dx; ++i) { | |
copier.copy(i, dy - 1, dx - i - 1, 0, 1, dy, -1); // TL | |
copier.copy(i, sh - 1, dx - i - 1, dy + sh, 1, dh - sh - dy, -1); // BL | |
copier.copy(sw - i - 1, dy - 1, dx + sw + i, 0, 1, dy, -1); // TR | |
copier.copy(sw - i - 1, sh - 1, dx + sw + i, dy + sh, 1, dh - sh - dy, -1); // TL | |
} | |
return dstImageData; | |
} | |
return null; | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export const normalizeKernel = (m, weight) => { | |
const mh = m.length, | |
mw = m[0].length; | |
if (typeof weight != 'number' || isNaN(weight)) { | |
weight = 1 / (mh * mw); | |
} | |
for (let j = 0; j < mh; ++j) { | |
for (let i = 0; i < mw; ++i) { | |
m[j][i] *= weight; | |
} | |
} | |
return m; | |
}; | |
// kernels | |
// taken from: https://en.wikipedia.org/wiki/Kernel_(image_processing) | |
export const edgeA = [ | |
[1, 0, -1], | |
[0, 0, 0], | |
[-1, 0, 1] | |
]; | |
edgeA.cx = edgeA.cy = 1; | |
export const edgeB = [ | |
[0, -1, 0], | |
[-1, 4, -1], | |
[0, -1, 0] | |
]; | |
edgeB.cx = edgeB.cy = 1; | |
export const edgeC = [ | |
[-1, -1, -1], | |
[-1, 8, -1], | |
[-1, -1, -1] | |
]; | |
edgeC.cx = edgeC.cy = 1; | |
export const sharpen = [ | |
[0, -1, 0], | |
[-1, 5, -1], | |
[0, -1, 0] | |
]; | |
sharpen.cx = sharpen.cy = 1; | |
export const boxBlur = normalizeKernel([ | |
[1, 1, 1], | |
[1, 1, 1], | |
[1, 1, 1] | |
]); | |
boxBlur.cx = boxBlur.cy = 1; | |
export const gaussianBlur3 = normalizeKernel( | |
[ | |
[1, 2, 1], | |
[2, 4, 2], | |
[1, 2, 1] | |
], | |
1 / 16 | |
); | |
gaussianBlur3.cx = gaussianBlur3.cy = 1; | |
export const gaussianBlur5 = normalizeKernel( | |
[ | |
[1, 4, 6, 4, 1], | |
[4, 16, 24, 16, 4], | |
[6, 24, 36, 24, 6], | |
[4, 16, 24, 16, 4], | |
[1, 4, 6, 4, 1] | |
], | |
1 / 256 | |
); | |
gaussianBlur5.cx = gaussianBlur5.cy = 2; | |
export const unsharp5 = normalizeKernel( | |
[ | |
[1, 4, 6, 4, 1], | |
[4, 16, 24, 16, 4], | |
[6, 24, -476, 24, 6], | |
[4, 16, 24, 16, 4], | |
[1, 4, 6, 4, 1] | |
], | |
-1 / 256 | |
); | |
unsharp5.cx = unsharp5.cy = 2; | |
export const emboss = [ | |
[-2, -1, 0], | |
[-1, 1, 1], | |
[0, 1, 2] | |
]; | |
emboss.cx = emboss.cy = 1; | |
// main applicator | |
export const applyFilter = (srcImageData, dstImageData, m, cx, cy) => { | |
const mh = m.length, | |
mw = m[0].length, | |
w = srcImageData.width, | |
h = srcImageData.height, | |
lw = w - mw, | |
lh = h - mh, | |
shift = (cy * w + cx) << 2, | |
src = srcImageData.data, | |
dst = dstImageData.data; | |
for (let j = 0; j < lh; ++j) { | |
for (let i = 0; i < lw; ++i) { | |
const p = (j * w + i) << 2; | |
for (let c = 0; c < 4; ++c) { | |
let acc = 0; | |
for (let mj = 0, py = p; mj < mh; ++mj, py += w << 2) { | |
for (let mi = 0, px = py; mi < mw; ++mi, px += 4) { | |
acc += m[mj][mi] * src[px + c]; | |
} | |
} | |
dst[p + shift + c] = Math.max(0, Math.min(255, Math.round(acc))); | |
} | |
} | |
} | |
return dstImageData; | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const assert = require('assert').strict; | |
export const createHistogram = () => { | |
let hist = [0, 0, 0, 0]; | |
hist = hist.concat(hist); // 8 | |
hist = hist.concat(hist); // 16 | |
hist = hist.concat(hist); // 32 | |
hist = hist.concat(hist); // 64 | |
hist = hist.concat(hist); // 128 | |
hist = hist.concat(hist); // 256 | |
return hist; | |
}; | |
export const getHistogram = imageData => { | |
const hist = createHistogram(), | |
data = imageData.data; | |
for (let i = (imageData.width * imageData.height - 1) << 2; i >= 0; i -= 4) { | |
++hist[Math.max(0, Math.min(255, Math.round(0.2125 * data[i] + 0.7154 * data[i + 1] + 0.0721 * data[i + 2])))]; | |
} | |
return hist; | |
}; | |
export const getHistogramHsl = imageData => { | |
const hist = createHistogram(), | |
data = imageData.data; | |
for (let i = (imageData.width * imageData.height - 1) << 2; i >= 0; i -= 4) { | |
const r = data[i], | |
g = data[i + 1], | |
b = data[i + 2], | |
l = Math.round((Math.min(r, g, b) + Math.max(r, g, b)) / 2); | |
++hist[l]; | |
} | |
return hist; | |
}; | |
export const createLut = (a, b) => { | |
const lut = []; | |
for (let i = 0; i < 256; ++i) { | |
lut.push(Math.max(0, Math.min(Math.round(a * i + b)))); | |
} | |
return lut; | |
}; | |
export const combineLuts = (...luts) => { | |
const lut = createHistogram(); | |
for (let i = 0; i < 256; ++i) { | |
let value = i; | |
for (let j = 0; j < luts.length; ++j) { | |
value = luts[j][value]; | |
} | |
lut[i] = value; | |
} | |
return lut; | |
}; | |
export const applyLut = (imageData, lut) => { | |
const data = imageData.data; | |
for (let i = (imageData.width * imageData.height - 1) << 2; i >= 0; i -= 4) { | |
data[i] = lut[data[i]]; | |
data[i + 1] = lut[data[i + 1]]; | |
data[i + 2] = lut[data[i + 2]]; | |
} | |
return imageData; | |
}; | |
export const applyLutHsl = (imageData, lut) => { | |
const data = imageData.data; | |
for (let i = (imageData.width * imageData.height - 1) << 2; i >= 0; i -= 4) { | |
// get RGB, convert to HSL | |
const r = data[i] / 255, | |
g = data[i + 1] / 255, | |
b = data[i + 2] / 255, | |
min = Math.min(r, g, b), | |
max = Math.max(r, g, b), | |
diff = max - min, | |
l = (min + max) / 2, | |
s = min == max ? 0 : l < 0.5 ? diff / (max + min) : diff / (2 - max - min); | |
let h = 0; | |
if (s) { | |
if (r === max) { | |
h = (g - b) / diff; | |
} else if (g === max) { | |
h = 2 + (b - r) / diff; | |
} else if (b === max) { | |
h = 4 + (r - g) / diff; | |
} | |
h *= 60; | |
if (h < 0) h += 360; | |
} | |
// applying LUT | |
const newL = lut[Math.round(l * 255)] / 255; | |
// convert new HSL back to RGB | |
if (!s) { | |
data[i] = data[i + 1] = data[i + 2] = Math.round(newL * 255); | |
continue; | |
} | |
const t1 = newL < 0.5 ? newL * (1 + s) : newL + s - newL * s, | |
t2 = 2 * newL - t1, | |
hue = h / 360, | |
channels = [hue + 0.333, hue, hue - 0.333]; | |
for (let j = 0; j < 3; ++j) { | |
let value = channels[j]; | |
value = value < 0 ? value + 1 : value > 1 ? value - 1 : value; | |
if (6 * value < 1) { | |
data[i + j] = Math.round((t2 + (t1 - t2) * 6 * value) * 255); | |
} else if (2 * value < 1) { | |
data[i + j] = Math.round(t1 * 255); | |
} else if (3 * value < 2) { | |
data[i + j] = Math.round((t2 + (t1 - t2) * 6 * (0.666 - value)) * 255); | |
} else { | |
data[i + j] = Math.round(t2 * 255); | |
} | |
} | |
} | |
return imageData; | |
}; | |
export const limitHist = (hist, below = 0.03, above = 0.03) => { | |
// collect total | |
let total = 0; | |
for (let i = 0; i < 256; ++i) { | |
total += hist[i]; | |
} | |
// find the lower bound | |
let sum = 0, | |
l = 0; | |
for (;;) { | |
const newSum = sum + hist[l]; | |
if (newSum / total > below) break; | |
sum = newSum; | |
++l; | |
} | |
// find the upper bound | |
sum = 0; | |
let r = 255; | |
for (;;) { | |
const newSum = sum + hist[r]; | |
if (newSum / total > above) break; | |
sum = newSum; | |
--r; | |
} | |
if (l >= r) return null; | |
const lut = [], | |
a = 255 / (r - l); | |
for (let i = 0; i < l; ++i) lut.push(0); | |
for (let i = l; i < r; ++i) lut.push(Math.round(a * (i - l))); | |
for (let i = r; i < 256; ++i) lut.push(255); | |
return lut; | |
}; | |
export const equalizeHist = hist => { | |
// build cumulative distribution function | |
const cdf = []; | |
let runningTotal = 0; | |
for (let i = 0; i < 256; ++i) { | |
if (hist[i]) { | |
runningTotal += hist[i]; | |
cdf.push(i, runningTotal); | |
} | |
} | |
if (!cdf.length) return null; | |
// build lut | |
const lut = [], minValue = cdf[1], span = runningTotal - minValue; | |
let lastIndex = -1, lastValue = 0; | |
for (let j = 0; j < cdf.length; j += 2) { | |
const index = cdf[j], value = (cdf[j + 1] - minValue) / span; | |
if (lastIndex + 1 < index) { | |
const a = (value - lastValue) / (index - lastIndex); | |
for (let i = lastIndex + 1; i < index; ++i) { | |
lut.push(Math.round(((i - lastIndex) * a + lastValue) * 255)); | |
} | |
} | |
lut.push(Math.round(value * 255)); | |
lastIndex = index; | |
lastValue = value; | |
} | |
for (let i = lastIndex + 1; i < 256; ++i) { | |
lut.push(255); | |
} | |
return lut; | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export const getDataUrl = async file => | |
new Promise(resolve => { | |
const reader = new FileReader(); | |
reader.onloadend = () => resolve(reader.result); | |
reader.readAsDataURL(file); | |
}); | |
export const getImage = async url => | |
new Promise((resolve, reject) => { | |
const image = new Image(); | |
image.onload = () => resolve(image); | |
image.onerror = e => reject(e); | |
image.src = url; | |
}); | |
export const getDataUrlFromImage = (image, ...args) => { | |
const canvas = document.createElement('canvas'), | |
ctx = canvas.getContext('2d'); | |
canvas.width = image.width; | |
canvas.height = image.height; | |
ctx.drawImage(image, 0, 0); | |
return canvas.toDataURL(...args); | |
}; | |
export const generateThumbnail = (image, width, height, filter, ...args) => { | |
const ratio = Math.min(width / image.width, height / image.height); | |
if (ratio >= 1) return getDataUrlFromImage(image, ...args); | |
const newWidth = Math.min(width, Math.ceil(image.width * ratio)), | |
newHeight = Math.min(height, Math.ceil(image.height * ratio)); | |
const canvas = document.createElement('canvas'), | |
ctx = canvas.getContext('2d'); | |
canvas.width = newWidth; | |
canvas.height = newHeight; | |
ctx.clearRect(0, 0, newWidth, newHeight); | |
ctx.drawImage(image, 0, 0, newWidth, newHeight); | |
if (typeof filter == 'function') { | |
filter(canvas, ctx); | |
} | |
return canvas.toDataURL(...args); | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {getDataUrl, getImage, generateThumbnail} from './images'; | |
import {unsharp5, applyFilter} from './imageFilters'; | |
import detectWebp from './detectWebp'; | |
const canUseWebp = detectWebp(), | |
parseDataUrl = /^data:([^;]+);/; | |
const makePreview = async (file, args = [], width = 128, height = 64) => { | |
if (!file || !/^image\//.test(file.type)) return null; | |
const dataUrl = await getDataUrl(file), | |
image = await getImage(dataUrl), | |
thumbUrls = {}; | |
thumbUrls['image/png'] = generateThumbnail(image, width, height, (canvas, ctx) => { | |
const src = ctx.getImageData(0, 0, canvas.width, canvas.height), | |
dst = ctx.createImageData(canvas.width, canvas.height), | |
filter = unsharp5; | |
applyFilter(src, dst, filter, filter.cx, filter.cy); | |
ctx.putImageData( | |
dst, | |
filter.cx, | |
filter.cy, | |
filter.cx, | |
filter.cy, | |
canvas.width - filter[0].length + filter.cx + 1, | |
canvas.height - filter.length + filter.cy + 1 | |
); | |
for (let i = 0; i < args.length; ++i) { | |
let type = args[i]; | |
if (typeof type == 'string') type = [type]; | |
if (!type[0] || (!canUseWebp && type[0] === 'image/webp')) continue; | |
try { | |
const url = canvas.toDataURL(...type); | |
if (!url) continue; | |
const parsed = parseDataUrl.exec(url); | |
if (!parsed || parsed[1] !== type[0]) continue; | |
thumbUrls[type[0]] = url; | |
} catch (e) { | |
// squelch | |
} | |
} | |
}); | |
return thumbUrls; | |
}; | |
export default makePreview; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment