WebGL Tutorial
and more

像素操作

撰写时间:2025-04-14

修订时间:2025-04-17

追踪被点亮像素的坐标

像素操作需要与图像底层数据的每个像素打交道,因此,一是数据量庞大;二是涉及众多对象,且对象间的关系较复杂;三是需绝对精准定位。因此,其复杂度往往超出我们原来的认知。

下面在一个10px × 10pxcanvas上,填充一个2行、2列的矩形,并列出各个对象的具体内容。

const { geoGrp: g, Point } = await import('/js/esm/geoGrp.js'); let imgData; const PointKey = ((x, y) => { const cache = {}; return function(x, y) { const key = `${x},${y}`; if (!cache[key]) { cache[key] = Point(x, y); } return cache[key]; }; })(); function init() { let targetDiv = pc.appendHTMLStr('
')[0]; g.createCanvas(targetDiv, 10, 10); g.ctx.fillStyle = 'red'; g.ctx.fillRect(0, 0, 2, 2); imgData = g.getAllImgData(); } function showImageDataInfo() { pc.log(imgData); pc.log(`width: %d, height: %d`, imgData.width, imgData.height); pc.groupCollapsed("data"); let data = imgData.data; pc.log(`length: %d`, data.length); pc.log(data); pc.groupEnd(); } function showPixelsInfo() { pc.group("Pixels"); pc.log(`total number: %d`, imgData.data.length / 4); pc.group('All high-lighted pixels indices'); let allHLPixelsIndices = g.getAllHilightPixelsIndices(); pc.group('[' + allHLPixelsIndices.join(', ') + ']'); pc.group('Group into Coordinates (x, y) in rows'); const coordsSet = new Set(); allHLPixelsIndices.forEach(pixelIndex => { const coords = g.getXYFromPixelIndex(imgData, pixelIndex); const [x, y] = coords; coordsSet.add(PointKey(x, y)); }); let rowsData = {}; for (const point of coordsSet) { if (!rowsData[point.y]) { rowsData[point.y] = []; } rowsData[point.y].push(point); } let entries = Object.entries(rowsData); for (const [rowId, points] of entries) { pc.groupCollapsed(`row ${rowId}`); for (let pt of points) { pc.groupCollapsed(`coords: (%d, %d)`, pt.x, pt.y); let pixelsIndices = g.getPixelsIndicesFromXY(imgData, pt.x, pt.y); pc.groupCollapsed(`pixel indices: [%s]`, pixelsIndices.join(', ')); pixelsIndices.forEach(pixelIndex => { let pixel = g.getPixel(imgData.data, pixelIndex); pc.log(`%d: rgba(%s)`, pixelIndex, pixel.join(', ')); }); pc.groupEnd(); pc.groupEnd(); } pc.groupEnd(); } pc.groupEnd(); pc.groupEnd(); pc.groupEnd(); pc.groupEnd(); } function showInfo() { pc.group("All image data"); showImageDataInfo(); showPixelsInfo(); pc.groupEnd(); } init(); showInfo();

上面代码填充了一个2px * 2px的矩形区域。

下面假设当divicePixelRatio的值为2时所出现的各种情况。

getAllHilightPixelsIndices找出所有的被点亮的像素索引值(共16个像素)。getXYFromPixelIndex根据每个像素的索引值,找出所对应的客户端的坐标值。总共只有4个坐标位置中的相应像素被点亮。上面运行效果对这4个坐标按行进行了分类。其坐标值如下表:

被点亮的坐标
012...9
0
1
2
...
9

因此,每个坐标均使用4个像素的存储空间来展现一个被点亮的CSS像素点。

getPixelsIndicesFromXY根据特定坐标来找出位于该坐标处所对应的4个像素的索引值,getPixel根据该索引值找出data数组中相应位置的颜色值,每个像素的颜色值均用4个数组元素来存储。

这段代码的意义在于,对于canvas中被点亮的任意像素,这段代码能帮助我们精准地追踪到哪些坐标的像素被点亮了。

取出所有被点亮的像素坐标

getAllHilightPoints是上面所有代码的集成,返回所有被点亮的点的坐标。

下面根据此函数的返回结果,使用另一个canvas来图示坐标、像素索引、颜色成分及devicePixelRatio相互间的复杂关系。

const { geoGrp: g, Point, Rect } = await import('/js/esm/geoGrp.js'); const { CanvasUtils } = await import('/js/esm/CanvasUtils.js'); function init() { let targetDiv = pc.appendHTMLStr('
')[0]; g.createCanvas(targetDiv, 20, 20); const { ctx } = g; ctx.beginPath(); ctx.strokeStyle = '#0F0'; ctx.fillStyle = '#F00'; ctx.lineWidth = 1; ctx.rect(5, 5, 3, 3); ctx.fill(); //ctx.stroke(); } function showInfo() { pc.groupCollapsed('hi-lighted coords'); let points = g.getAllHilightPoints(); points.forEach(pt => { pc.log(`(%d, %d)`, pt.x, pt.y); }); pc.groupEnd(); let imgData = g.getAllImgData(); showCoordsInCanvas(points, g.canvas.clientWidth, g.canvas.clientHeight, imgData); } function showCoordsInCanvas(points, totalWidth, totalHeight, imgData) { let targetDiv = pc.appendHTMLStr(`
`)[0]; let ctx = CanvasUtils.CreateAndInitCanvasContext(targetDiv, 500, 500); let canvas = ctx.canvas; let colorBlocks = []; let colorPs = []; for (let i = 0; i < devicePixelRatio * devicePixelRatio; i++) { let colorBlock = document.createElement('div'); targetDiv.appendChild(colorBlock); colorBlocks.push(colorBlock); let colorP = document.createElement('p'); targetDiv.appendChild(colorP); colorPs.push(colorP); } let styleText = ` #graph-div { margin: 0; padding: 0; display: grid; grid-template-columns: auto 1fr; gap: 0.5em; justify-items: left; & > canvas { grid-column: 1 / span 2; border: 1px solid gray; margin: 0em; } & > div { width: 20px; height: 20px; border: 1px solid gray; } & > p { margin: 0; line-height: 1em; } } `; pc.appendInlineStyle(styleText); let rows = totalWidth + 1; let cols = totalHeight + 1; const PADDING = 10; const ROW_HEIGHT = (canvas.clientHeight - PADDING * 2) / rows; const COL_WIDTH = (canvas.clientWidth - PADDING * 2) / cols; let startX = PADDING; let startY = PADDING; let endX = canvas.clientWidth - PADDING; let endY = canvas.clientHeight - PADDING; ctx.beginPath(); ctx.strokeStyle = '#444'; // horz lines for (let row = 0, currY = startY; row <= rows; row++, currY += ROW_HEIGHT) { ctx.moveTo(startX, currY); ctx.lineTo(endX, currY); } // vert lines for (let col = 0, gridX = startX; col <= cols; col++, gridX += COL_WIDTH) { ctx.moveTo(gridX, startY); ctx.lineTo(gridX, endY); } ctx.stroke(); ctx.font = '8px Helvetica'; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; ctx.fillStyle = '#999'; for (let col = 1, gridX = startX + COL_WIDTH; col <= cols - 1; col++, gridX += COL_WIDTH) { let cellCenterX = gridX + COL_WIDTH / 2; let cellCenterY = startY + ROW_HEIGHT / 2; ctx.fillText(col-1, cellCenterX, cellCenterY); } for (let row = 1, gridY = startY + ROW_HEIGHT; row <= rows - 1; row++, gridY += ROW_HEIGHT) { let cellCenterX = startX + COL_WIDTH / 2; let cellCenterY = gridY + ROW_HEIGHT / 2; ctx.fillText(row-1, cellCenterX, cellCenterY); } let cellMinSide = Math.min(COL_WIDTH, ROW_HEIGHT); let r = cellMinSide / 2 * 0.3; startX += COL_WIDTH; startY += ROW_HEIGHT; points.forEach(pt => { const {x, y} = pt; let cellCenterX = startX + (COL_WIDTH) * x + COL_WIDTH / 2; let cellCenterY = startY + (ROW_HEIGHT) * y + ROW_HEIGHT / 2; ctx.beginPath(); ctx.fillStyle = '#AAA'; ctx.arc(cellCenterX, cellCenterY, r, 0, Math.PI * 2); ctx.fill(); }); canvas.addEventListener('mousemove', (evt) => { onMouseMove(evt.offsetX, evt.offsetY); }); const CELL_START_X = PADDING + COL_WIDTH; const CELL_START_Y = PADDING + ROW_HEIGHT; function getCellId(mouseX, mouseY) { let x = parseInt((mouseX - CELL_START_X) / COL_WIDTH); let y = parseInt((mouseY - CELL_START_Y) / ROW_HEIGHT); return Point(x, y); } let savedCellImageData = { cellId: undefined, cellXY: {}, imgData: {} }; function onMouseMove(mouseX, mouseY) { if (mouseX < CELL_START_X || mouseX > canvas.clientWidth - PADDING || mouseY < CELL_START_Y || mouseY > canvas.clientHeight - PADDING ) { return; } function saveAndPlot(cellId) { let currCellX = CELL_START_X + cellId.x * COL_WIDTH; let currCellY = CELL_START_Y + cellId.y * ROW_HEIGHT; let localImgData = ctx.getImageData(currCellX * devicePixelRatio, currCellY * devicePixelRatio, COL_WIDTH * devicePixelRatio, ROW_HEIGHT * devicePixelRatio); savedCellImageData.cellId = cellId; savedCellImageData.cellXY = Point(currCellX, currCellY); savedCellImageData.imgData = localImgData; ctx.save(); ctx.fillStyle = '#9993'; ctx.fillRect(currCellX + 2, currCellY + 2, COL_WIDTH - 4, ROW_HEIGHT - 4); ctx.restore(); } const cellId = getCellId(mouseX, mouseY); if (savedCellImageData.cellId === undefined) { saveAndPlot(cellId); } else { if (savedCellImageData.cellId.x !== cellId.x || savedCellImageData.cellId.y !== cellId.y) { ctx.putImageData(savedCellImageData.imgData, savedCellImageData.cellXY.x * devicePixelRatio, savedCellImageData.cellXY.y * devicePixelRatio); saveAndPlot(cellId); } } const {x: imgDataX, y: imgDataY} = cellId; let pixelsIndices = g.getPixelsIndicesFromXY(imgData, imgDataX, imgDataY); pixelsIndices.forEach((pixelIndex, idx) => { let color = g.getPixel(imgData.data, pixelIndex); colorBlocks[idx].style.backgroundColor = `rgb(${color[0]}, ${color[1]}, ${color[2]})`; colorPs[idx].textContent = `[${pixelIndex}]: ${color}`; }); } } init(); showInfo();

填充区域时比较简单,但描边且应用了devicePixelRatio后各像素的颜色值远比想像中复杂。

取出4个角

get4Corners在内部调用getAllHilightPoints以取出边界框线的4个端点。

const { geoGrp: g, Point, Rect } = await import('/js/esm/geoGrp.js'); let corners; function init() { let targetDiv = pc.appendHTMLStr('
')[0]; g.createCanvas(targetDiv, 100, 100); g.ctx.beginPath(); g.ctx.fillStyle = 'lightseagreen'; g.ctx.arc(50, 50, 30, 0, Math.PI * 2); g.ctx.fill(); } function showInfo() { /* pc.group('coords'); let points = g.getAllHilightPoints(); pc.log(points); pc.groupEnd(); */ pc.group('4 corners'); corners = g.get4Corners(); pc.log(corners); pc.groupEnd(); } function drawRect() { const {left, top, right, bottom} = corners; let rect = Rect(left, top, right - left + 1, bottom - top + 1); const {x, y, width, height} = rect; g.ctx.strokeStyle = 'gray'; g.ctx.strokeRect(x, y, width, height); } init(); showInfo(); drawRect();

在不需要打印所有加亮顶点的情况下,可放心扩大canvas的尺寸,以及绘制更大的图像。然后,根据4个端点构建一个矩形边框并绘制它,从肉眼上观察边框是否正好包裹住所有的图形。

居中重绘图像

下面在一个较大的canvas上,在随意的坐标绘制任意图形,然后调用centerImage,将图像居中重绘。

function drawImg() { const { ctx } = g; ctx.font = '180px Helvetica'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#BBB'; ctx.fillText('\u{23F0}', 100, 150); } function centerImage() { g.centerImage(); } drawImg(); centerImage();

只要初始绘制的图像没有超出canvas的边界而被默认地裁剪,则可完美地居中所有的图像内容。

getMinMaxXY

get4Corners是根据所有像素数据来获取边角,如果像素数据太多,则这种算法效率低效。getMinMaxXY则在坐标系先后进行上下左右的扫描以确定有图像内容的边界框顶点。根据图像像素数量与canvas尺寸的占比,占比越大,所花时间越少;但如果占比太小,则不如get4Corners的算法高效。

const { geoGrp: g, Point, Rect } = await import('/js/esm/geoGrp.js'); let corners; function init() { let targetDiv = pc.appendHTMLStr('
')[0]; g.createCanvas(targetDiv, 100, 100); g.canvas.style.border = '1px solid gray'; g.ctx.beginPath(); g.ctx.fillStyle = 'lightseagreen'; g.ctx.arc(70, 25, 15, 0, Math.PI * 2); g.ctx.fill(); } function showInfo() { pc.group('4 corners'); corners = g.get4Corners(); pc.log(corners); pc.groupEnd(); pc.group('Getting min max X Y'); let obj = g.getMinMaxXY(); pc.log(obj); pc.groupEnd(); } function drawRect() { let rect = g.getImgBoundingRect(); const {x, y, width, height} = rect; g.ctx.strokeStyle = 'gray'; g.ctx.strokeRect(x, y, width, height); pc.group('Bounding Rect'); pc.log(rect); pc.groupEnd(); } init(); showInfo(); drawRect();

在应用devicePixelRatio时,由于一个坐标对应着多个像素,最小值取自由这些像素所组成的数组的第0个元素,最大值则需要考虑devicePixelRatio分别在X轴与Y轴上所造成的偏移值。getMinMaxXY在内部予以了考虑。

上面代码同时列出get4CornersgetMinMaxXY所返回的边界端点,这两个结果应完全一样。

getImgBoundingRect在内部调用getMinMaxXY来创建并返回一个表示图像边界框的Rect

描边问题

下面代码与上节代码一样,但在填充后,再进行描边。

const { geoGrp: g, Point, Rect } = await import('/js/esm/geoGrp.js'); let corners; function init() { let targetDiv = pc.appendHTMLStr('
')[0]; g.createCanvas(targetDiv, 100, 100); g.canvas.style.border = '1px solid gray'; g.ctx.beginPath(); g.ctx.fillStyle = 'lightseagreen'; g.ctx.arc(70, 25, 15, 0, Math.PI * 2); g.ctx.fill(); g.ctx.lineWidth = 1; g.ctx.strokeStyle = 'red'; g.ctx.stroke(); } function showInfo() { pc.group('4 corners'); corners = g.get4Corners(); pc.log(corners); pc.groupEnd(); pc.group('Getting min max X Y'); let obj = g.getMinMaxXY(); pc.log(obj); pc.groupEnd(); } function drawRect() { let rect = g.getImgBoundingRect(); const {x, y, width, height} = rect; g.ctx.lineWidth = 1; g.ctx.strokeStyle = 'gray'; g.ctx.strokeRect(x, y, width, height); pc.group('Bounding Rect'); pc.log(rect); pc.groupEnd(); } init(); showInfo(); drawRect();

描边的默认宽度为1像素,与上节数据对比可看出,描边是在填充区域的外部进行,从而导致x向左偏移1像素,y向上偏移1个像素,widthheight的值均增加了2个像素。

但如果描边宽度不为1时,可发现一个有趣的现象。

const { geoGrp: g, Point, Rect } = await import('/js/esm/geoGrp.js'); let corners; function init() { let targetDiv = pc.appendHTMLStr('
')[0]; g.createCanvas(targetDiv, 100, 100); g.canvas.style.border = '1px solid gray'; g.ctx.beginPath(); g.ctx.fillStyle = 'lightseagreen'; g.ctx.arc(70, 25, 15, 0, Math.PI * 2); g.ctx.fill(); g.ctx.lineWidth = 10; g.ctx.strokeStyle = 'red'; g.ctx.stroke(); } function showInfo() { pc.group('coords'); let points = g.getAllHilightPoints(); pc.log(points); pc.groupEnd(); pc.group('4 corners'); corners = g.get4Corners(); pc.log(corners); pc.groupEnd(); pc.group('Getting min max X Y'); let obj = g.getMinMaxXY(); pc.log(obj); pc.groupEnd(); } function drawRect() { let rect = g.getImgBoundingRect(); const {x, y, width, height} = rect; g.ctx.lineWidth = 1; g.ctx.strokeStyle = 'gray'; g.ctx.strokeRect(x, y, width, height); pc.group('Bounding Rect'); pc.log(rect); pc.groupEnd(); } init(); showInfo(); drawRect();

可以看出,当描边宽度值为10时,则描边区域在填充区域内外各取5像素,导致填充区域相应区域被覆盖,填充区域变小了。

改变描边宽度,有以下规律:

  • 如果lineWidth能被2整除,则在填充区域内外各取一半进行描边。
  • 如果lineWidth不能被2整除,则先整除,在填充区域内外各取一半进行描边;将余数1,放在填充区域进行描边。

参考资源

  1. Safari HTML5 Canvas Guide
  2. HTML5 Canvas Element