第6章:图像与像素操作
6.1 章节概述
在前面的章节中,我们学习了如何使用 Canvas 绑制各种图形。但在实际项目中,我们经常需要处理图像——加载照片、应用滤镜、进行图像合成等。Canvas 提供了强大的图像处理能力,从简单的图片绘制到像素级的操作,都能轻松实现。
本章将深入讲解:
- 图像加载:如何将图片加载到 Canvas
- 图像绘制:drawImage 的各种用法
- 图像裁剪:从图像中提取特定区域
- 像素操作:直接访问和修改像素数据
- 图像滤镜:实现灰度、模糊、锐化等效果
- 图像导出:将 Canvas 内容导出为图片
学完本章后,你将能够构建图像编辑器、实现图片滤镜、进行图像合成等高级功能。
6.2 理解 Canvas 中的图像
6.2.1 什么是图像?
在计算机中,图像本质上是一个像素矩阵。每个像素包含颜色信息:
图像 = 宽度 × 高度 的像素网格
┌───┬───┬───┬───┬───┐
│ P │ P │ P │ P │ P │ P = 像素 (Pixel)
├───┼───┼───┼───┼───┤
│ P │ P │ P │ P │ P │ 每个像素包含:
├───┼───┼───┼───┼───┤ - R (红色分量: 0-255)
│ P │ P │ P │ P │ P │ - G (绿色分量: 0-255)
├───┼───┼───┼───┼───┤ - B (蓝色分量: 0-255)
│ P │ P │ P │ P │ P │ - A (透明度: 0-255)
└───┴───┴───┴───┴───┘6.2.2 Canvas 支持的图像源
Canvas 的 drawImage() 方法可以接受多种图像源:
| 图像源类型 | 说明 | 常见用途 |
|---|---|---|
HTMLImageElement | <img> 标签或 new Image() | 最常用,加载外部图片 |
HTMLCanvasElement | 另一个 Canvas 元素 | Canvas 之间复制内容 |
HTMLVideoElement | <video> 标签 | 视频帧截取 |
ImageBitmap | 预解码的位图 | 高性能图像处理 |
OffscreenCanvas | 离屏 Canvas | Web Worker 中使用 |
SVGImageElement | SVG 图像 | 矢量图形 |
6.2.3 图像加载的异步性
重要概念:图像加载是异步的!
这是初学者最容易犯的错误之一:
// ❌ 错误示例:图像还没加载完就绘制
const img = new Image();
img.src = 'photo.jpg';
ctx.drawImage(img, 0, 0); // 此时图像可能还是空的!
// ✅ 正确示例:等待图像加载完成
const img = new Image();
img.onload = function() {
ctx.drawImage(img, 0, 0); // 图像已加载,可以绘制
};
img.src = 'photo.jpg'; // 设置 src 后开始加载理解加载流程:
时间轴 →
img.src = 'photo.jpg'
│
├──────────── 网络请求 ────────────┤
│ │
│ (这期间 img 还是空的) │
│ │
│ img.onload 触发
│ │
▼ ▼
[开始加载] [加载完成,可以绘制]6.3 图像加载方法
6.3.1 使用 Image 对象
最基础的图像加载方式:
/**
* 加载单张图片
* @param {string} src - 图片 URL
* @returns {Promise<HTMLImageElement>} - 加载完成的图片对象
*/
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
resolve(img);
};
img.onerror = (error) => {
reject(new Error(`图片加载失败: ${src}`));
};
img.src = src;
});
}
// 使用
async function init() {
try {
const img = await loadImage('photo.jpg');
console.log(`图片尺寸: ${img.width} × ${img.height}`);
ctx.drawImage(img, 0, 0);
} catch (error) {
console.error(error);
}
}
init();6.3.2 加载多张图片
当需要加载多张图片时,使用 Promise.all 并行加载:
/**
* 批量加载图片
* @param {string[]} sources - 图片 URL 数组
* @returns {Promise<HTMLImageElement[]>} - 所有图片对象
*/
async function loadImages(sources) {
const promises = sources.map(src => loadImage(src));
return Promise.all(promises);
}
// 使用
async function init() {
const sources = [
'background.jpg',
'character.png',
'item1.png',
'item2.png'
];
try {
const images = await loadImages(sources);
console.log(`成功加载 ${images.length} 张图片`);
// 按顺序绘制
images.forEach((img, index) => {
ctx.drawImage(img, index * 100, 0);
});
} catch (error) {
console.error('有图片加载失败:', error);
}
}6.3.3 加载进度显示
对于大量图片,显示加载进度可以提升用户体验:
/**
* 带进度回调的图片批量加载
* @param {string[]} sources - 图片 URL 数组
* @param {Function} onProgress - 进度回调 (loaded, total)
* @returns {Promise<HTMLImageElement[]>}
*/
async function loadImagesWithProgress(sources, onProgress) {
const total = sources.length;
let loaded = 0;
const images = [];
for (const src of sources) {
const img = await loadImage(src);
images.push(img);
loaded++;
onProgress(loaded, total);
}
return images;
}
// 使用
loadImagesWithProgress(sources, (loaded, total) => {
const percent = Math.round((loaded / total) * 100);
console.log(`加载进度: ${percent}%`);
// 更新 UI
progressBar.style.width = `${percent}%`;
progressText.textContent = `${loaded} / ${total}`;
});6.3.4 跨域图片加载
当图片来自不同域名时,需要处理跨域问题:
function loadCrossOriginImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
// 设置跨域属性(必须在设置 src 之前)
img.crossOrigin = 'anonymous'; // 或 'use-credentials'
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`跨域图片加载失败: ${src}`));
img.src = src;
});
}跨域属性说明:
| 值 | 说明 |
|---|---|
anonymous | 不发送凭据(cookies 等) |
use-credentials | 发送凭据,服务器需要返回正确的 CORS 头 |
注意:即使设置了 crossOrigin,服务器也必须返回正确的 CORS 响应头:
Access-Control-Allow-Origin: *
// 或指定具体域名
Access-Control-Allow-Origin: https://your-domain.com6.3.5 使用 ImageBitmap
ImageBitmap 是预解码的位图,性能更好:
/**
* 使用 createImageBitmap 加载图片
* 优点:图片在主线程外解码,不阻塞 UI
*/
async function loadImageBitmap(src) {
const response = await fetch(src);
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
return bitmap;
}
// 使用
async function init() {
const bitmap = await loadImageBitmap('photo.jpg');
ctx.drawImage(bitmap, 0, 0);
// 使用完后释放资源
bitmap.close();
}
// 高级选项:指定解码参数
async function loadImageBitmapWithOptions(src) {
const response = await fetch(src);
const blob = await response.blob();
// 可以在创建时进行裁剪和缩放
const bitmap = await createImageBitmap(blob, {
resizeWidth: 200,
resizeHeight: 150,
resizeQuality: 'high', // 'pixelated' | 'low' | 'medium' | 'high'
imageOrientation: 'flipY' // 垂直翻转
});
return bitmap;
}6.3.6 从 Data URL 加载
有时图片是 Base64 编码的 Data URL:
function loadFromDataURL(dataURL) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = dataURL; // Data URL 不需要异步加载,但 onload 仍会触发
});
}
// Base64 Data URL 示例
const dataURL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA...';
const img = await loadFromDataURL(dataURL);6.3.7 从文件选择器加载
让用户选择本地图片:
// HTML
// <input type="file" id="fileInput" accept="image/*">
const fileInput = document.getElementById('fileInput');
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
// 方法1:使用 FileReader
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => ctx.drawImage(img, 0, 0);
img.src = event.target.result;
};
reader.readAsDataURL(file);
// 方法2:使用 createObjectURL(更高效)
const url = URL.createObjectURL(file);
const img = await loadImage(url);
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url); // 释放 URL
// 方法3:使用 createImageBitmap(最高效)
const bitmap = await createImageBitmap(file);
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
});6.4 绘制图像:drawImage
6.4.1 drawImage 的三种形式
drawImage 是 Canvas 中最重要的图像方法,有三种调用形式:
形式1:基础绘制
ctx.drawImage(image, dx, dy);将图像绘制到指定位置,保持原始尺寸。
| 参数 | 含义 |
|---|---|
| image | 图像源 |
| dx | 目标 X 坐标 |
| dy | 目标 Y 坐标 |
// 在 (50, 50) 位置绘制原始尺寸图片
ctx.drawImage(img, 50, 50);形式2:缩放绘制
ctx.drawImage(image, dx, dy, dWidth, dHeight);将图像绘制到指定位置并缩放到指定尺寸。
| 参数 | 含义 |
|---|---|
| image | 图像源 |
| dx, dy | 目标位置 |
| dWidth | 目标宽度 |
| dHeight | 目标高度 |
// 将图片缩放到 200×150 并绘制
ctx.drawImage(img, 50, 50, 200, 150);
// 保持比例缩放
const scale = 0.5;
ctx.drawImage(img, 0, 0, img.width * scale, img.height * scale);形式3:裁剪 + 缩放绘制(完整形式)
ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);从源图像裁剪一个区域,然后缩放绘制到目标区域。
| 参数 | 含义 |
|---|---|
| sx, sy | 源图像裁剪起点 |
| sWidth, sHeight | 源图像裁剪尺寸 |
| dx, dy | 目标位置 |
| dWidth, dHeight | 目标尺寸 |
源图像 (image) Canvas (目标)
┌─────────────────────┐ ┌────────────────┐
│ │ │ │
│ ┌─────────┐ │ │ ┌──────┐ │
│ │ (sx,sy) │ │ → │ │(dx,dy) │
│ │ │ │ drawImage│ │ │ │
│ │ sW×sH │ │ │ │ dW×dH│ │
│ └─────────┘ │ │ └──────┘ │
│ │ │ │
└─────────────────────┘ └────────────────┘// 从图片的 (100, 50) 位置裁剪 200×100 区域
// 绘制到 Canvas 的 (0, 0) 位置,大小为 400×200
ctx.drawImage(img,
100, 50, 200, 100, // 源:裁剪区域
0, 0, 400, 200 // 目标:绘制区域
);6.4.2 保持图像比例
缩放图像时,通常需要保持原始比例以避免变形:
/**
* 计算保持比例的缩放尺寸
* @param {number} srcWidth - 源宽度
* @param {number} srcHeight - 源高度
* @param {number} maxWidth - 最大宽度
* @param {number} maxHeight - 最大高度
* @returns {Object} - { width, height }
*/
function fitSize(srcWidth, srcHeight, maxWidth, maxHeight) {
const ratio = Math.min(
maxWidth / srcWidth,
maxHeight / srcHeight
);
return {
width: srcWidth * ratio,
height: srcHeight * ratio
};
}
// 使用
const fit = fitSize(img.width, img.height, 300, 200);
ctx.drawImage(img, 0, 0, fit.width, fit.height);三种常见的缩放模式:
/**
* 缩放模式
*/
const ScaleMode = {
// 完全包含在容器内(可能有空白)
CONTAIN: 'contain',
// 完全覆盖容器(可能裁剪)
COVER: 'cover',
// 拉伸填充(可能变形)
FILL: 'fill'
};
function calculateScale(srcW, srcH, dstW, dstH, mode) {
switch (mode) {
case ScaleMode.CONTAIN: {
const ratio = Math.min(dstW / srcW, dstH / srcH);
return {
width: srcW * ratio,
height: srcH * ratio,
x: (dstW - srcW * ratio) / 2,
y: (dstH - srcH * ratio) / 2
};
}
case ScaleMode.COVER: {
const ratio = Math.max(dstW / srcW, dstH / srcH);
return {
width: srcW * ratio,
height: srcH * ratio,
x: (dstW - srcW * ratio) / 2,
y: (dstH - srcH * ratio) / 2
};
}
case ScaleMode.FILL:
default:
return {
width: dstW,
height: dstH,
x: 0,
y: 0
};
}
}
// 使用
const scale = calculateScale(img.width, img.height, 400, 300, ScaleMode.CONTAIN);
ctx.drawImage(img, scale.x, scale.y, scale.width, scale.height);缩放模式图示:
原图 (4:3 比例) 容器 (1:1 比例)
┌────────────┐ CONTAIN COVER FILL
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 图片 │ → │ ──────── │ │▓▓▓▓▓▓▓▓▓▓│ │ 拉伸变形 │
│ │ │ │图片 │ │ │▓▓图片▓▓▓▓│ │ │
└────────────┘ │ ──────── │ │▓▓▓▓▓▓▓▓▓▓│ │ │
└──────────┘ └──────────┘ └──────────┘
有空白边距 裁剪了两侧 变形6.4.3 图像平铺
/**
* 平铺图像填充区域
*/
function tileImage(ctx, img, x, y, width, height) {
const cols = Math.ceil(width / img.width);
const rows = Math.ceil(height / img.height);
ctx.save();
// 创建裁剪区域
ctx.beginPath();
ctx.rect(x, y, width, height);
ctx.clip();
// 平铺绘制
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
ctx.drawImage(img,
x + col * img.width,
y + row * img.height
);
}
}
ctx.restore();
}
// 或者使用 createPattern(更高效)
function tileImagePattern(ctx, img, x, y, width, height) {
const pattern = ctx.createPattern(img, 'repeat');
ctx.fillStyle = pattern;
ctx.fillRect(x, y, width, height);
}6.4.4 Sprite 精灵图
游戏开发中常用精灵图(Sprite Sheet)来减少网络请求:
精灵图示例(角色行走动画):
┌────┬────┬────┬────┐
│ 帧1 │ 帧2 │ 帧3 │ 帧4 │ 每帧 64×64 像素
└────┴────┴────┴────┘
↑ ↑ ↑ ↑
静止 迈左脚 行走中 迈右脚class Sprite {
constructor(image, frameWidth, frameHeight) {
this.image = image;
this.frameWidth = frameWidth;
this.frameHeight = frameHeight;
this.cols = Math.floor(image.width / frameWidth);
this.rows = Math.floor(image.height / frameHeight);
this.totalFrames = this.cols * this.rows;
}
/**
* 绘制指定帧
* @param {CanvasRenderingContext2D} ctx
* @param {number} frameIndex - 帧索引 (从 0 开始)
* @param {number} x - 目标 X
* @param {number} y - 目标 Y
* @param {number} [scale=1] - 缩放比例
*/
drawFrame(ctx, frameIndex, x, y, scale = 1) {
// 计算帧在精灵图中的位置
const col = frameIndex % this.cols;
const row = Math.floor(frameIndex / this.cols);
const sx = col * this.frameWidth;
const sy = row * this.frameHeight;
ctx.drawImage(
this.image,
sx, sy, this.frameWidth, this.frameHeight, // 源区域
x, y, this.frameWidth * scale, this.frameHeight * scale // 目标区域
);
}
}
// 使用
const spriteSheet = await loadImage('character.png');
const sprite = new Sprite(spriteSheet, 64, 64);
// 绘制第 3 帧(索引从 0 开始)
sprite.drawFrame(ctx, 2, 100, 100);带动画的精灵:
class AnimatedSprite extends Sprite {
constructor(image, frameWidth, frameHeight, fps = 12) {
super(image, frameWidth, frameHeight);
this.fps = fps;
this.currentFrame = 0;
this.frameTime = 1000 / fps;
this.lastFrameTime = 0;
// 动画定义
this.animations = {};
this.currentAnimation = null;
}
/**
* 定义动画
* @param {string} name - 动画名称
* @param {number[]} frames - 帧索引数组
* @param {boolean} [loop=true] - 是否循环
*/
defineAnimation(name, frames, loop = true) {
this.animations[name] = { frames, loop };
}
/**
* 播放动画
*/
play(animationName) {
if (this.currentAnimation !== animationName) {
this.currentAnimation = animationName;
this.currentFrame = 0;
}
}
/**
* 更新动画
*/
update(timestamp) {
if (!this.currentAnimation) return;
const elapsed = timestamp - this.lastFrameTime;
if (elapsed >= this.frameTime) {
const anim = this.animations[this.currentAnimation];
this.currentFrame++;
if (this.currentFrame >= anim.frames.length) {
if (anim.loop) {
this.currentFrame = 0;
} else {
this.currentFrame = anim.frames.length - 1;
}
}
this.lastFrameTime = timestamp;
}
}
/**
* 绘制当前帧
*/
draw(ctx, x, y, scale = 1) {
if (!this.currentAnimation) return;
const anim = this.animations[this.currentAnimation];
const frameIndex = anim.frames[this.currentFrame];
this.drawFrame(ctx, frameIndex, x, y, scale);
}
}
// 使用
const character = new AnimatedSprite(spriteSheet, 64, 64, 8);
character.defineAnimation('idle', [0]);
character.defineAnimation('walk', [0, 1, 2, 3]);
character.defineAnimation('run', [4, 5, 6, 7]);
character.play('walk');
function gameLoop(timestamp) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
character.update(timestamp);
character.draw(ctx, 100, 100, 2);
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);6.5 像素操作基础
6.5.1 什么是像素操作?
像素操作允许我们直接读取和修改 Canvas 中的像素数据。这是实现图像滤镜、颜色调整、图像分析等高级功能的基础。
6.5.2 ImageData 对象
ImageData 是存储像素数据的对象:
// ImageData 结构
{
width: 图像宽度(像素),
height: 图像高度(像素),
data: Uint8ClampedArray // 像素数据数组
}data 数组结构:
data 是一个一维数组,每 4 个元素表示一个像素:
像素 0 像素 1 像素 2 ...
┌───────┐ ┌───────┐ ┌───────┐
[R,G,B,A, R,G,B,A, R,G,B,A, ...]
0 1 2 3 4 5 6 7 8 9 10 11
数组长度 = width × height × 4Uint8ClampedArray 的特点:
- 每个值是 0-255 的整数
- 超出范围的值会被自动截断(<0 变成 0,>255 变成 255)
6.5.3 获取像素数据
// 获取整个 Canvas 的像素数据
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// 获取指定区域的像素数据
const regionData = ctx.getImageData(x, y, width, height);
// 访问像素
const data = imageData.data;
const width = imageData.width;
const height = imageData.height;
console.log(`像素数量: ${width * height}`);
console.log(`数据长度: ${data.length}`); // width * height * 46.5.4 访问特定像素
/**
* 获取指定位置像素的颜色
* @param {ImageData} imageData
* @param {number} x - X 坐标
* @param {number} y - Y 坐标
* @returns {Object} - { r, g, b, a }
*/
function getPixel(imageData, x, y) {
const { data, width } = imageData;
// 计算像素在数组中的起始索引
// 每行有 width 个像素,每个像素占 4 个元素
const index = (y * width + x) * 4;
return {
r: data[index],
g: data[index + 1],
b: data[index + 2],
a: data[index + 3]
};
}
/**
* 设置指定位置像素的颜色
*/
function setPixel(imageData, x, y, r, g, b, a = 255) {
const { data, width } = imageData;
const index = (y * width + x) * 4;
data[index] = r;
data[index + 1] = g;
data[index + 2] = b;
data[index + 3] = a;
}
// 使用示例
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// 读取 (100, 50) 位置的像素
const pixel = getPixel(imageData, 100, 50);
console.log(`颜色: rgba(${pixel.r}, ${pixel.g}, ${pixel.b}, ${pixel.a})`);
// 设置 (100, 50) 位置为红色
setPixel(imageData, 100, 50, 255, 0, 0, 255);
// 写回 Canvas
ctx.putImageData(imageData, 0, 0);6.5.5 像素索引公式
理解索引计算是像素操作的关键:
对于坐标 (x, y) 的像素:
┌───────────────────────────────────┐
│ │
│ y 行 × width 像素/行 = 前面行的像素总数
│ │
│ + x = 当前像素是第几个(从 0 开始)
│ │
│ × 4 = 数组中的起始索引(每像素 4 元素)
│ │
└───────────────────────────────────┘
index = (y * width + x) * 4
然后:
data[index] = R
data[index + 1] = G
data[index + 2] = B
data[index + 3] = A示意图:
假设 Canvas 宽度为 5:
y=0: [像素0] [像素1] [像素2] [像素3] [像素4]
y=1: [像素5] [像素6] [像素7] [像素8] [像素9]
y=2: [像素10][像素11][像素12][像素13][像素14]
↑
(1, 2) = 像素 11 = 索引 11×4 = 446.5.6 遍历所有像素
/**
* 遍历所有像素
*/
function forEachPixel(imageData, callback) {
const { data, width, height } = imageData;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const index = (y * width + x) * 4;
// 获取当前像素
const r = data[index];
const g = data[index + 1];
const b = data[index + 2];
const a = data[index + 3];
// 调用回调,可能返回新颜色
const result = callback(r, g, b, a, x, y);
// 如果返回了新颜色,更新像素
if (result) {
data[index] = result.r;
data[index + 1] = result.g;
data[index + 2] = result.b;
data[index + 3] = result.a ?? a;
}
}
}
}
// 使用示例:将所有像素变红
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
forEachPixel(imageData, (r, g, b, a) => {
return { r: 255, g: 0, b: 0 }; // 返回红色
});
ctx.putImageData(imageData, 0, 0);优化版本(更快):
/**
* 高性能像素遍历
* 直接操作数组,避免函数调用开销
*/
function processPixels(imageData) {
const { data } = imageData;
const len = data.length;
// 每次处理 4 个元素(一个像素)
for (let i = 0; i < len; i += 4) {
// 直接修改
data[i] = 255; // R
data[i + 1] = 0; // G
data[i + 2] = 0; // B
// data[i + 3] 保持 A 不变
}
}6.5.7 创建新的 ImageData
// 方法1:创建空白 ImageData
const emptyData = ctx.createImageData(width, height);
// 所有像素初始为透明黑色 (0, 0, 0, 0)
// 方法2:创建与现有 ImageData 相同大小的空白数据
const sameSize = ctx.createImageData(existingImageData);
// 方法3:使用构造函数
const customData = new ImageData(width, height);
// 方法4:从已有数组创建
const pixelArray = new Uint8ClampedArray(width * height * 4);
// ... 填充数据 ...
const fromArray = new ImageData(pixelArray, width, height);6.5.8 putImageData 的使用
// 基本用法:将 ImageData 绘制到指定位置
ctx.putImageData(imageData, dx, dy);
// 高级用法:只绘制 ImageData 的一部分
ctx.putImageData(imageData, dx, dy, dirtyX, dirtyY, dirtyWidth, dirtyHeight);注意:putImageData 会完全替换目标区域,忽略:
- 当前变换矩阵
- 全局透明度
- 裁剪区域
- 阴影效果
- 混合模式
// 这些设置对 putImageData 无效!
ctx.globalAlpha = 0.5; // 无效
ctx.globalCompositeOperation = 'lighter'; // 无效
ctx.translate(100, 100); // 无效
ctx.putImageData(imageData, 0, 0); // 仍然绘制到 (0, 0)6.6 图像滤镜实现
6.6.1 灰度滤镜
将彩色图像转换为黑白图像:
/**
* 灰度滤镜
* 灰度公式: gray = 0.299*R + 0.587*G + 0.114*B
* (人眼对绿色最敏感,对蓝色最不敏感)
*/
function grayscale(imageData) {
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
// 加权平均法(更接近人眼感知)
const gray = 0.299 * r + 0.587 * g + 0.114 * b;
data[i] = gray; // R
data[i + 1] = gray; // G
data[i + 2] = gray; // B
// A 保持不变
}
return imageData;
}
// 使用
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
grayscale(imageData);
ctx.putImageData(imageData, 0, 0);6.6.2 亮度调整
/**
* 调整亮度
* @param {ImageData} imageData
* @param {number} brightness - 亮度调整值 (-255 到 255)
*/
function adjustBrightness(imageData, brightness) {
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
data[i] = data[i] + brightness; // R
data[i + 1] = data[i + 1] + brightness; // G
data[i + 2] = data[i + 2] + brightness; // B
// Uint8ClampedArray 会自动截断到 0-255
}
return imageData;
}6.6.3 对比度调整
/**
* 调整对比度
* @param {number} contrast - 对比度因子 (0 到 2+,1 为原始)
*/
function adjustContrast(imageData, contrast) {
const data = imageData.data;
const factor = (259 * (contrast * 255 + 255)) / (255 * (259 - contrast * 255));
for (let i = 0; i < data.length; i += 4) {
data[i] = factor * (data[i] - 128) + 128;
data[i + 1] = factor * (data[i + 1] - 128) + 128;
data[i + 2] = factor * (data[i + 2] - 128) + 128;
}
return imageData;
}6.6.4 反色滤镜
/**
* 反色滤镜
*/
function invert(imageData) {
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
data[i] = 255 - data[i]; // R
data[i + 1] = 255 - data[i + 1]; // G
data[i + 2] = 255 - data[i + 2]; // B
}
return imageData;
}6.6.5 色调分离(阈值)
/**
* 色调分离 - 将图像转换为纯黑白
* @param {number} threshold - 阈值 (0-255)
*/
function threshold(imageData, threshold = 128) {
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
const value = gray >= threshold ? 255 : 0;
data[i] = value;
data[i + 1] = value;
data[i + 2] = value;
}
return imageData;
}6.6.6 褐色滤镜(怀旧效果)
/**
* 褐色/怀旧滤镜
*/
function sepia(imageData) {
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
data[i] = r * 0.393 + g * 0.769 + b * 0.189;
data[i + 1] = r * 0.349 + g * 0.686 + b * 0.168;
data[i + 2] = r * 0.272 + g * 0.534 + b * 0.131;
}
return imageData;
}6.6.7 卷积滤镜(核心概念)
许多高级滤镜(模糊、锐化、边缘检测)都基于卷积运算。
什么是卷积?
卷积使用一个小矩阵(称为"核"或"卷积核")在图像上滑动,将核的值与对应像素相乘后求和。
原图像像素 卷积核 (3×3) 计算过程
┌───┬───┬───┐ ┌───┬───┬───┐
│ a │ b │ c │ │ 1 │ 1 │ 1 │
├───┼───┼───┤ × ├───┼───┼───┤ = a×1 + b×1 + c×1 +
│ d │ e │ f │ │ 1 │ 1 │ 1 │ d×1 + e×1 + f×1 +
├───┼───┼───┤ ├───┼───┼───┤ g×1 + h×1 + i×1
│ g │ h │ i │ │ 1 │ 1 │ 1 │
└───┴───┴───┘ └───┴───┴───┘ 然后除以 9(均值模糊)/**
* 通用卷积滤镜
* @param {ImageData} imageData
* @param {number[]} kernel - 卷积核(一维数组,长度为 9/25/49...)
* @param {number} [divisor] - 除数,默认为核的值之和
*/
function convolve(imageData, kernel, divisor) {
const { data, width, height } = imageData;
const output = new Uint8ClampedArray(data); // 创建副本
const size = Math.sqrt(kernel.length);
const half = Math.floor(size / 2);
// 计算除数
if (!divisor) {
divisor = kernel.reduce((sum, val) => sum + val, 0) || 1;
}
for (let y = half; y < height - half; y++) {
for (let x = half; x < width - half; x++) {
let r = 0, g = 0, b = 0;
// 应用卷积核
for (let ky = -half; ky <= half; ky++) {
for (let kx = -half; kx <= half; kx++) {
const px = x + kx;
const py = y + ky;
const idx = (py * width + px) * 4;
const k = kernel[(ky + half) * size + (kx + half)];
r += data[idx] * k;
g += data[idx + 1] * k;
b += data[idx + 2] * k;
}
}
const outIdx = (y * width + x) * 4;
output[outIdx] = r / divisor;
output[outIdx + 1] = g / divisor;
output[outIdx + 2] = b / divisor;
}
}
// 复制结果到原数据
imageData.data.set(output);
return imageData;
}6.6.8 模糊滤镜
// 均值模糊(Box Blur)
const boxBlurKernel = [
1, 1, 1,
1, 1, 1,
1, 1, 1
];
convolve(imageData, boxBlurKernel);
// 高斯模糊(更平滑)
const gaussianBlurKernel = [
1, 2, 1,
2, 4, 2,
1, 2, 1
];
convolve(imageData, gaussianBlurKernel, 16);
// 5×5 高斯模糊(更强)
const gaussianBlur5x5 = [
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
];
convolve(imageData, gaussianBlur5x5, 256);6.6.9 锐化滤镜
// 锐化
const sharpenKernel = [
0, -1, 0,
-1, 5, -1,
0, -1, 0
];
convolve(imageData, sharpenKernel);
// 强锐化
const strongSharpenKernel = [
-1, -1, -1,
-1, 9, -1,
-1, -1, -1
];
convolve(imageData, strongSharpenKernel);6.6.10 边缘检测
// Sobel 边缘检测 - 水平方向
const sobelHorizontal = [
-1, 0, 1,
-2, 0, 2,
-1, 0, 1
];
// Sobel 边缘检测 - 垂直方向
const sobelVertical = [
-1, -2, -1,
0, 0, 0,
1, 2, 1
];
// 拉普拉斯边缘检测
const laplacianKernel = [
0, 1, 0,
1, -4, 1,
0, 1, 0
];
/**
* 完整的边缘检测
*/
function detectEdges(imageData) {
// 先转灰度
grayscale(imageData);
// 应用拉普拉斯算子
convolve(imageData, [
-1, -1, -1,
-1, 8, -1,
-1, -1, -1
]);
return imageData;
}6.6.11 使用 CSS 滤镜(简便方法)
Canvas 2D 上下文也支持 CSS 滤镜,更简单但不够灵活:
// 设置滤镜
ctx.filter = 'blur(5px)';
ctx.drawImage(img, 0, 0);
// 多个滤镜组合
ctx.filter = 'grayscale(100%) contrast(150%) brightness(110%)';
ctx.drawImage(img, 0, 0);
// 重置滤镜
ctx.filter = 'none';
// 可用的 CSS 滤镜:
// - blur(px):模糊
// - brightness(%):亮度
// - contrast(%):对比度
// - grayscale(%):灰度
// - sepia(%):褐色
// - saturate(%):饱和度
// - hue-rotate(deg):色相旋转
// - invert(%):反色
// - opacity(%):透明度
// - drop-shadow():阴影6.7 图像导出
6.7.1 导出为 Data URL
// 导出为 PNG(默认)
const pngDataURL = canvas.toDataURL();
// 或明确指定
const pngDataURL2 = canvas.toDataURL('image/png');
// 导出为 JPEG
const jpegDataURL = canvas.toDataURL('image/jpeg');
// JPEG 质量设置(0-1)
const jpegHighQuality = canvas.toDataURL('image/jpeg', 0.95);
const jpegLowQuality = canvas.toDataURL('image/jpeg', 0.5);
// 导出为 WebP(部分浏览器支持)
const webpDataURL = canvas.toDataURL('image/webp', 0.8);
// 使用 Data URL
const downloadLink = document.createElement('a');
downloadLink.href = pngDataURL;
downloadLink.download = 'image.png';
downloadLink.click();6.7.2 导出为 Blob
// 异步导出为 Blob
canvas.toBlob((blob) => {
// 下载
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'image.png';
link.click();
URL.revokeObjectURL(url);
}, 'image/png');
// 使用 Promise 包装
function canvasToBlob(canvas, type = 'image/png', quality) {
return new Promise((resolve) => {
canvas.toBlob(resolve, type, quality);
});
}
// 使用
async function downloadCanvas() {
const blob = await canvasToBlob(canvas, 'image/jpeg', 0.9);
// ... 处理 blob
}6.7.3 上传到服务器
async function uploadCanvas(canvas, uploadUrl) {
const blob = await canvasToBlob(canvas, 'image/png');
const formData = new FormData();
formData.append('image', blob, 'canvas.png');
const response = await fetch(uploadUrl, {
method: 'POST',
body: formData
});
return response.json();
}6.7.4 复制到剪贴板
async function copyCanvasToClipboard(canvas) {
try {
const blob = await canvasToBlob(canvas, 'image/png');
await navigator.clipboard.write([
new ClipboardItem({
'image/png': blob
})
]);
console.log('已复制到剪贴板');
} catch (error) {
console.error('复制失败:', error);
}
}6.8 实战应用
6.8.1 图片取色器
class ColorPicker {
constructor(canvas, img) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.img = img;
this.setup();
}
setup() {
// 绘制图片
this.canvas.width = this.img.width;
this.canvas.height = this.img.height;
this.ctx.drawImage(this.img, 0, 0);
// 获取像素数据
this.imageData = this.ctx.getImageData(
0, 0, this.canvas.width, this.canvas.height
);
// 添加事件监听
this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this));
this.canvas.addEventListener('click', this.onClick.bind(this));
}
getColorAt(x, y) {
const { data, width } = this.imageData;
const index = (y * width + x) * 4;
return {
r: data[index],
g: data[index + 1],
b: data[index + 2],
a: data[index + 3]
};
}
colorToHex(color) {
const toHex = (n) => n.toString(16).padStart(2, '0');
return `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`;
}
onMouseMove(e) {
const rect = this.canvas.getBoundingClientRect();
const x = Math.floor(e.clientX - rect.left);
const y = Math.floor(e.clientY - rect.top);
if (x >= 0 && x < this.canvas.width && y >= 0 && y < this.canvas.height) {
const color = this.getColorAt(x, y);
this.showPreview(color);
}
}
onClick(e) {
const rect = this.canvas.getBoundingClientRect();
const x = Math.floor(e.clientX - rect.left);
const y = Math.floor(e.clientY - rect.top);
const color = this.getColorAt(x, y);
const hex = this.colorToHex(color);
console.log(`选择的颜色: ${hex}`);
this.onColorPicked?.(color, hex);
}
showPreview(color) {
// 实现预览 UI
}
}6.8.2 简单图像编辑器
class SimpleImageEditor {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.history = [];
this.historyIndex = -1;
}
async loadImage(src) {
const img = await loadImage(src);
this.canvas.width = img.width;
this.canvas.height = img.height;
this.ctx.drawImage(img, 0, 0);
this.saveState();
}
saveState() {
// 删除当前位置之后的历史
this.history = this.history.slice(0, this.historyIndex + 1);
// 保存当前状态
const imageData = this.ctx.getImageData(
0, 0, this.canvas.width, this.canvas.height
);
this.history.push(imageData);
this.historyIndex++;
// 限制历史记录数量
if (this.history.length > 50) {
this.history.shift();
this.historyIndex--;
}
}
undo() {
if (this.historyIndex > 0) {
this.historyIndex--;
this.ctx.putImageData(this.history[this.historyIndex], 0, 0);
}
}
redo() {
if (this.historyIndex < this.history.length - 1) {
this.historyIndex++;
this.ctx.putImageData(this.history[this.historyIndex], 0, 0);
}
}
applyFilter(filterFunc) {
const imageData = this.ctx.getImageData(
0, 0, this.canvas.width, this.canvas.height
);
filterFunc(imageData);
this.ctx.putImageData(imageData, 0, 0);
this.saveState();
}
grayscale() {
this.applyFilter(grayscale);
}
invert() {
this.applyFilter(invert);
}
brightness(value) {
this.applyFilter((data) => adjustBrightness(data, value));
}
blur() {
this.applyFilter((data) => {
convolve(data, [1,2,1, 2,4,2, 1,2,1], 16);
});
}
sharpen() {
this.applyFilter((data) => {
convolve(data, [0,-1,0, -1,5,-1, 0,-1,0]);
});
}
export(format = 'image/png', quality = 0.92) {
return this.canvas.toDataURL(format, quality);
}
}
// 使用
const editor = new SimpleImageEditor(canvas);
await editor.loadImage('photo.jpg');
editor.brightness(20);
editor.grayscale();
editor.undo();
editor.sharpen();
const dataURL = editor.export('image/jpeg', 0.9);6.8.3 图像对比滑块
class ImageCompareSlider {
constructor(container, beforeImg, afterImg) {
this.container = container;
this.beforeImg = beforeImg;
this.afterImg = afterImg;
this.setup();
}
setup() {
// 创建 Canvas
this.canvas = document.createElement('canvas');
this.canvas.width = this.beforeImg.width;
this.canvas.height = this.beforeImg.height;
this.ctx = this.canvas.getContext('2d');
this.container.appendChild(this.canvas);
// 初始位置 50%
this.sliderPosition = 0.5;
this.draw();
this.bindEvents();
}
draw() {
const { ctx, canvas, beforeImg, afterImg, sliderPosition } = this;
const splitX = canvas.width * sliderPosition;
// 绘制 "之前" 图片
ctx.drawImage(beforeImg, 0, 0);
// 绘制 "之后" 图片(只显示分割线右边)
ctx.save();
ctx.beginPath();
ctx.rect(splitX, 0, canvas.width - splitX, canvas.height);
ctx.clip();
ctx.drawImage(afterImg, 0, 0);
ctx.restore();
// 绘制分割线
ctx.beginPath();
ctx.moveTo(splitX, 0);
ctx.lineTo(splitX, canvas.height);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
// 绘制滑块手柄
ctx.beginPath();
ctx.arc(splitX, canvas.height / 2, 20, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
ctx.strokeStyle = '#333';
ctx.stroke();
}
bindEvents() {
let isDragging = false;
this.canvas.addEventListener('mousedown', () => {
isDragging = true;
});
window.addEventListener('mouseup', () => {
isDragging = false;
});
this.canvas.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const rect = this.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
this.sliderPosition = Math.max(0, Math.min(1, x / this.canvas.width));
this.draw();
});
}
}6.9 性能优化
6.9.1 减少 getImageData/putImageData 调用
// ❌ 低效:多次调用
for (let i = 0; i < 10; i++) {
const data = ctx.getImageData(0, 0, w, h);
applyFilter(data);
ctx.putImageData(data, 0, 0);
}
// ✅ 高效:一次调用,多次处理
const data = ctx.getImageData(0, 0, w, h);
for (let i = 0; i < 10; i++) {
applyFilter(data);
}
ctx.putImageData(data, 0, 0);6.9.2 使用 Web Worker 处理像素
// main.js
const worker = new Worker('filter-worker.js');
worker.onmessage = (e) => {
const processedData = new ImageData(
new Uint8ClampedArray(e.data.buffer),
e.data.width,
e.data.height
);
ctx.putImageData(processedData, 0, 0);
};
// 发送数据到 Worker
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
worker.postMessage({
buffer: imageData.data.buffer,
width: imageData.width,
height: imageData.height,
filter: 'grayscale'
}, [imageData.data.buffer]); // 转移所有权,避免复制// filter-worker.js
self.onmessage = (e) => {
const { buffer, width, height, filter } = e.data;
const data = new Uint8ClampedArray(buffer);
// 在 Worker 中处理像素
switch (filter) {
case 'grayscale':
for (let i = 0; i < data.length; i += 4) {
const gray = data[i] * 0.299 + data[i+1] * 0.587 + data[i+2] * 0.114;
data[i] = data[i+1] = data[i+2] = gray;
}
break;
// ... 其他滤镜
}
self.postMessage({ buffer: data.buffer, width, height }, [data.buffer]);
};6.9.3 使用 OffscreenCanvas
// 在主线程创建 OffscreenCanvas
const offscreen = new OffscreenCanvas(800, 600);
const offCtx = offscreen.getContext('2d');
// 或转移现有 Canvas 的控制权给 Worker
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);6.10 本章小结
本章深入介绍了 Canvas 的图像处理能力:
核心方法
| 方法 | 作用 |
|---|---|
drawImage() | 绘制图像(3种形式) |
getImageData() | 获取像素数据 |
putImageData() | 写入像素数据 |
createImageData() | 创建空白 ImageData |
toDataURL() | 导出为 Data URL |
toBlob() | 导出为 Blob |
像素操作要点
| 概念 | 说明 |
|---|---|
| ImageData | 存储像素的对象 |
| 索引公式 | (y * width + x) * 4 |
| RGBA | 每像素 4 字节 |
| 卷积 | 高级滤镜的基础 |
常用滤镜
| 滤镜 | 原理 |
|---|---|
| 灰度 | RGB 加权平均 |
| 反色 | 255 - 原值 |
| 亮度 | 加/减固定值 |
| 模糊 | 卷积(均值/高斯核) |
| 锐化 | 卷积(锐化核) |
6.11 练习题
基础练习
实现一个图片加载器,支持显示加载进度
使用 drawImage 实现图片裁剪功能
实现一个颜色取色器
进阶练习
实现以下滤镜:
- 色彩增强(提高饱和度)
- 暗角效果
- 马赛克效果
创建一个带有撤销/重做功能的图片编辑器
挑战练习
- 实现一个完整的图片处理工具:
- 加载本地图片
- 多种滤镜选择
- 实时预览
- 导出功能
下一章预告:在第7章中,我们将学习合成与混合模式——如何控制图层叠加效果,实现各种视觉混合效果。
文档版本:v1.0
字数统计:约 18,000 字
代码示例:60+ 个
