我需要使用 JavaScript 在浏览器中进行计算密集型图像像素操作。像素操作包括直方图均衡、使用亮度(CIE Lab 色彩空间的 L*)转换为灰度。
为了不阻塞 UI 线程,我想在专用的 Web Worker 中执行此像素操作。
处理后的图像不会仅一次直接绘制到画布上。图像可以缩放和平移,一些其他形状将绘制在其上。因此,我无法使用
canvas.transferControlToOffscreen()
。
我想出了一个解决方案,使用构造函数创建
OffsceenCanvas
,绘制图像并获取 ImageData
。然后将 ImageData
作为可传输对象发送给工作人员 worker.postMessage(imageData, [imageData.data.buffer])
,并在处理后将其从工作人员发送回主线程 self.postMessage(imageData, [imageData.data.buffer])
。
这是一个最优的解决方案还是还有其他更有效的模式?
main.js
const canvas = document.getElementById('canvas');
const zoom = 1, offsetX = 0, offsetY = 0;
const offscreen, offscreenCtx;
const draw = () => {
if (!offscreen) {
return;
}
const ctx = canvas.getContext('2d');
ctx.save();
// simplified zooming and panning
ctx.scale(zoom, zoom);
ctx.translate(offsetX, offsetY);
ctx.drawImage(offscreen, 0, 0);
ctx.restore();
};
canvas.addEventListener('wheel', e => {
e.preventDefault();
if (e.deltaY > 0) {
zoom *= 1.1;
} else {
zoom /= 1.1;
}
draw();
});
const worker = new Worker(new URL('./worker.js', import.meta.url), {
type: 'module',
});
worker.addEventListener('message', message => {
const imageData = message.data;
offscreenCtx.putImageData(imageData, 0, 0);
draw();
});
const image = new Image();
image.addEventListener('load', () => {
offscreen = new OffscreenCanvas(image.width, image.height);
offscreenCtx = canvas.getContext('2d');
offscreenCtx.drawImage(image, 0, 0);
const imageData = offscreenCtx.getImageData(0, 0, image.width, image.height);
// clear the offscreen canvas to make sure the unprocessed image can't be sh
offscreenCtx.clearRect(0, 0, image.width, image.height);
worker.postMessage(imageData, [imageData.data.buffer]);
});
document.getElementById('image-select').addEventListener('change', e) => {
const files = e.target.files;
if (files && files.length > 0) {
const file = files[0];
if (/image\/.*/.test(file.type)) {
image.src = URL.createObjectURL(file);
}
}
});
worker.js
self.addEventListener('message', message => {
const imageData = message.data;
pixelManipulation(imageData);
self.postMessage(imageData, [imageData.data.buffer]);
self.close();
});
图像可以缩放和平移,一些其他形状将绘制在其上。因此,我无法使用 canvas.transferControlToOffscreen()。
不知道你是如何得出这个结论的。您可以很好地在工作人员中的
OffcreenCanvas
上绘制缩放或平移图像,并在占位符画布上渲染。EventPort
原型,可以帮助解决这种情况。
现在,如果您确实不希望所有代码都位于工作线程中,您仍然可以将大部分正在执行的操作移到那里。
不要使用
HTMLImageElement
来加载和解码您的图像,而是直接使用工作线程中的 createImageBitmap(Blob)
,这意味着您必须自己 fetch()
您的图像,但您将节省处理时间。
ImageBitmap#getImageData()
方法,但是比通过 OffscreenCanvas
执行性能更好的路径是使用 VideoFrame
然后将其数据复制到缓冲区中,尽管它仍然是实验性的(仅在Chromium 浏览器)并且我们仍然没有办法强制执行 RGBA 格式,所以我暂时将其隐藏,但它可能仍然有用。
async function readImageData(url) {
const resp = await fetch(url);
if (!resp.ok) { throw "Network Error"; }
const blob = await resp.blob();
const bmp = await createImageBitmap(blob);
const { width, height } = bmp;
if ("VideoFrame" in window) {
const frame = new VideoFrame(bmp, { timestamp: 0 })
bmp.close();
const { format } = frame;
if (format !== "RGBA" || format !== "RGBX") {
console.warn("This image would require a special parsing logic for: ", format);
}
const arr = new Uint8Array(frame.allocationSize());
frame.copyTo(arr);
frame.close();
return { width, height, data: arr, format: frame.format };
}
console.warn("using legacy canvas2d.getImageData()");
const canvas = new OffscreenCanvas(bmp.width, bmp.height);
const ctx = canvas.getContext("2d", { willReadFrequently: true });
ctx.drawImage(bmp, 0, 0);
bmp.close();
return ctx.getImageData(0, 0, width, height);
}
readImageData("https://upload.wikimedia.org/wikipedia/commons/thumb/9/9d/Elakha.jpg/320px-Elakha.jpg")
.then(console.log)
.catch(console.error);
因此,我们在工作程序中对图像进行解码,提取像素数据,进行处理,现在仍然是将
ImageData
转换为位图,这是您在调用 putImageData()
时执行的操作。ImageData
意味着两步光栅化,首先对未变换的光栅化,然后变换该位图。ImageData
传递到主线程,而是再次使用 createImageBitmap()
传递 ImageData
对象,并将生成的 ImageBitmap
发送到渲染线程。您将能够直接从那里进行转换:
const workerContent = document.querySelector("[type=worker]").textContent;
const workerURL = URL.createObjectURL(new Blob([workerContent], { type: "text/javascript" }));
const worker = new Worker(workerURL);
worker.onmessage = ({data: bmp}) => {
const canvas = document.querySelector("canvas");
// Rendering rotated 90deg
canvas.width = bmp.height;
canvas.height = bmp.width;
const ctx = canvas.getContext("2d");
ctx.translate(canvas.width/2, canvas.height/2);
ctx.rotate(Math.PI/2);
ctx.drawImage(bmp, -bmp.width/2, -bmp.height/2);
};
worker.postMessage("https://upload.wikimedia.org/wikipedia/commons/thumb/9/9d/Elakha.jpg/320px-Elakha.jpg");
<script type="worker">
function process({ data }) {
for (let i = 0; i<data.length; i+=4) {
const r = data[i];
data[i] = data[i+1];
data[i+1] = r;
}
};
onmessage = async ({ data: url }) => {
const resp = await fetch(url);
if (!resp.ok) { throw "Network Error"; }
const blob = await resp.blob();
const source = await createImageBitmap(blob);
const { width, height } = source;
// Using legacy context2d.getImageData() for now.
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext("2d", { willReadFrequently: true });
ctx.drawImage(source, 0, 0);
source.close(); // free memory, we don't need it anymore
const imageData = ctx.getImageData(0, 0, width, height);
process(imageData);
const bmp = await createImageBitmap(imageData);
// Transferring the ImageBitmap auto closes it.
self.postMessage(bmp, [bmp]);
}
</script>
<canvas></canvas>
最后,不要创建一次性工作人员,以可重用的方式编写代码。启动一个worker是一项繁重的操作,相比之下保持它的存活就很好了。
因此,如果您需要处理多个图像,或者即使您需要在另一个线程中执行其他工作,请保持该工作线程处于活动状态并使其处理所有这些工作。