Skip to content

第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离屏 CanvasWeb Worker 中使用
SVGImageElementSVG 图像矢量图形

6.2.3 图像加载的异步性

重要概念:图像加载是异步的!

这是初学者最容易犯的错误之一:

javascript
// ❌ 错误示例:图像还没加载完就绘制
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 对象

最基础的图像加载方式:

javascript
/**
 * 加载单张图片
 * @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 并行加载:

javascript
/**
 * 批量加载图片
 * @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 加载进度显示

对于大量图片,显示加载进度可以提升用户体验:

javascript
/**
 * 带进度回调的图片批量加载
 * @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 跨域图片加载

当图片来自不同域名时,需要处理跨域问题:

javascript
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.com

6.3.5 使用 ImageBitmap

ImageBitmap 是预解码的位图,性能更好:

javascript
/**
 * 使用 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:

javascript
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 从文件选择器加载

让用户选择本地图片:

javascript
// 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:基础绘制

javascript
ctx.drawImage(image, dx, dy);

将图像绘制到指定位置,保持原始尺寸。

参数含义
image图像源
dx目标 X 坐标
dy目标 Y 坐标
javascript
// 在 (50, 50) 位置绘制原始尺寸图片
ctx.drawImage(img, 50, 50);

形式2:缩放绘制

javascript
ctx.drawImage(image, dx, dy, dWidth, dHeight);

将图像绘制到指定位置并缩放到指定尺寸。

参数含义
image图像源
dx, dy目标位置
dWidth目标宽度
dHeight目标高度
javascript
// 将图片缩放到 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:裁剪 + 缩放绘制(完整形式)

javascript
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│      │
│   └─────────┘       │           │  └──────┘      │
│                     │           │                │
└─────────────────────┘           └────────────────┘
javascript
// 从图片的 (100, 50) 位置裁剪 200×100 区域
// 绘制到 Canvas 的 (0, 0) 位置,大小为 400×200
ctx.drawImage(img, 
    100, 50, 200, 100,   // 源:裁剪区域
    0, 0, 400, 200        // 目标:绘制区域
);

6.4.2 保持图像比例

缩放图像时,通常需要保持原始比例以避免变形:

javascript
/**
 * 计算保持比例的缩放尺寸
 * @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);

三种常见的缩放模式

javascript
/**
 * 缩放模式
 */
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 图像平铺

javascript
/**
 * 平铺图像填充区域
 */
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 像素
└────┴────┴────┴────┘
  ↑     ↑     ↑     ↑
 静止  迈左脚 行走中 迈右脚
javascript
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);

带动画的精灵

javascript
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 是存储像素数据的对象:

javascript
// 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 × 4

Uint8ClampedArray 的特点:

  • 每个值是 0-255 的整数
  • 超出范围的值会被自动截断(<0 变成 0,>255 变成 255)

6.5.3 获取像素数据

javascript
// 获取整个 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 * 4

6.5.4 访问特定像素

javascript
/**
 * 获取指定位置像素的颜色
 * @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 = 44

6.5.6 遍历所有像素

javascript
/**
 * 遍历所有像素
 */
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);

优化版本(更快):

javascript
/**
 * 高性能像素遍历
 * 直接操作数组,避免函数调用开销
 */
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

javascript
// 方法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 的使用

javascript
// 基本用法:将 ImageData 绘制到指定位置
ctx.putImageData(imageData, dx, dy);

// 高级用法:只绘制 ImageData 的一部分
ctx.putImageData(imageData, dx, dy, dirtyX, dirtyY, dirtyWidth, dirtyHeight);

注意putImageData 会完全替换目标区域,忽略:

  • 当前变换矩阵
  • 全局透明度
  • 裁剪区域
  • 阴影效果
  • 混合模式
javascript
// 这些设置对 putImageData 无效!
ctx.globalAlpha = 0.5;  // 无效
ctx.globalCompositeOperation = 'lighter';  // 无效
ctx.translate(100, 100);  // 无效

ctx.putImageData(imageData, 0, 0);  // 仍然绘制到 (0, 0)

6.6 图像滤镜实现

6.6.1 灰度滤镜

将彩色图像转换为黑白图像:

javascript
/**
 * 灰度滤镜
 * 灰度公式: 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 亮度调整

javascript
/**
 * 调整亮度
 * @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 对比度调整

javascript
/**
 * 调整对比度
 * @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 反色滤镜

javascript
/**
 * 反色滤镜
 */
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 色调分离(阈值)

javascript
/**
 * 色调分离 - 将图像转换为纯黑白
 * @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 褐色滤镜(怀旧效果)

javascript
/**
 * 褐色/怀旧滤镜
 */
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(均值模糊)
javascript
/**
 * 通用卷积滤镜
 * @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 模糊滤镜

javascript
// 均值模糊(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 锐化滤镜

javascript
// 锐化
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 边缘检测

javascript
// 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 滤镜,更简单但不够灵活:

javascript
// 设置滤镜
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

javascript
// 导出为 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

javascript
// 异步导出为 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 上传到服务器

javascript
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 复制到剪贴板

javascript
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 图片取色器

javascript
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 简单图像编辑器

javascript
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 图像对比滑块

javascript
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 调用

javascript
// ❌ 低效:多次调用
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 处理像素

javascript
// 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]);  // 转移所有权,避免复制
javascript
// 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

javascript
// 在主线程创建 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 练习题

基础练习

  1. 实现一个图片加载器,支持显示加载进度

  2. 使用 drawImage 实现图片裁剪功能

  3. 实现一个颜色取色器

进阶练习

  1. 实现以下滤镜:

    • 色彩增强(提高饱和度)
    • 暗角效果
    • 马赛克效果
  2. 创建一个带有撤销/重做功能的图片编辑器

挑战练习

  1. 实现一个完整的图片处理工具:
    • 加载本地图片
    • 多种滤镜选择
    • 实时预览
    • 导出功能

下一章预告:在第7章中,我们将学习合成与混合模式——如何控制图层叠加效果,实现各种视觉混合效果。


文档版本:v1.0
字数统计:约 18,000 字
代码示例:60+ 个

如有转载或 CV 的请标注本站原文地址