getImageData(x, y, w, h) returns RGBA bytes, row by row; putImageData() writes them back, ignoring transform, alpha and compositing. Both use bitmap pixels. Reads are slow on a GPU canvas, so pass willReadFrequently: true to the first getContext() call (Baseline since September 2024).
<canvas id="c" style="width:600px; height:62px"></canvas><script src="hidpi.js"></script>
<script>
c.getContext('2d', { willReadFrequently: true }); // the first call fixes the options
const ctx = hidpi(c), dpr = devicePixelRatio;
const img = Object.assign(new Image(), { src: 'booknest-logo.png' });
img.decode().then(() => {
[0, 205, 410].forEach(x => ctx.drawImage(img, x, 0, 190, 60));
const edit = (x, f) => { // f maps r, g, b to [r, g, b]
const pixels = ctx.getImageData(x * dpr, 0, 190 * dpr, 60 * dpr), d = pixels.data;
for (let i = 0; i < d.length; i += 4) d.set(f(d[i], d[i + 1], d[i + 2]), i);
ctx.putImageData(pixels, x * dpr, 0);
};
edit(205, (r, g, b) => Array(3).fill(0.2126 * r + 0.7152 * g + 0.0722 * b));
edit(410, (r, g, b) => [255 - r, 255 - g, 255 - b]);
window.__done = true;
});
</script>
Luminance weights make green read lighter than blue, as the eye sees it. Drawing a cross-origin image without CORS taints the canvas, and reading it then throws a SecurityError: set img.crossOrigin = 'anonymous' and have the server send Access-Control-Allow-Origin.
<!doctype html>
<style>
body { margin: 0; padding: 8px; font: 13px system-ui, sans-serif; background: #fafaf7; }
canvas { width: 100%; max-width: 600px; height: 190px; display: block; background: #fff; border: 1px solid #ddd; }
</style>
<canvas id="c"></canvas>
<script>
const c = document.getElementById('c');
// The first getContext() call fixes the options: ask for fast reads up front
c.getContext('2d', { willReadFrequently: true });
function hidpi(c, r = devicePixelRatio) {
c.width = c.clientWidth * r; c.height = c.clientHeight * r;
const ctx = c.getContext('2d'); ctx.scale(r, r); return ctx;
}
const ctx = hidpi(c), dpr = devicePixelRatio;
// Draw one colourful cover
function cover(x) {
const g = ctx.createLinearGradient(x, 0, x + 180, 160);
g.addColorStop(0, '#1f5f8b'); g.addColorStop(0.5, '#3f7d3a'); g.addColorStop(1, '#e09a10');
ctx.fillStyle = g; ctx.fillRect(x, 10, 180, 150);
ctx.fillStyle = '#b5452f'; ctx.beginPath(); ctx.arc(x + 130, 60, 28, 0, 2 * Math.PI); ctx.fill();
ctx.fillStyle = '#fff3e0'; ctx.font = 'bold 20px Georgia'; ctx.fillText('The Quiet', x + 12, 120);
ctx.fillText('Harbor', x + 12, 145);
}
[0, 205, 410].forEach(cover);
// Read a region, map each pixel's r, g, b, write it back (bitmap pixels, not CSS pixels)
function edit(x, f) {
const pixels = ctx.getImageData(x * dpr, 10 * dpr, 180 * dpr, 150 * dpr), d = pixels.data;
for (let i = 0; i < d.length; i += 4) d.set(f(d[i], d[i + 1], d[i + 2]), i);
ctx.putImageData(pixels, x * dpr, 10 * dpr);
}
// Luminance weights: green reads lighter than blue, as the eye sees it
edit(205, (r, g, b) => Array(3).fill(0.2126 * r + 0.7152 * g + 0.0722 * b));
edit(410, (r, g, b) => [255 - r, 255 - g, 255 - b]);
ctx.font = '12px sans-serif'; ctx.fillStyle = '#263238';
['original', 'grayscale (luminance)', 'inverted'].forEach((t, i) => ctx.fillText(t, i * 205, 178));
</script>