【Canvas 2D】day42-boss-filter-editor

作者:mario 发布时间: 2026-09-01 阅读量:5 评论数:0

Day 42 BOSS 战:图片滤镜编辑器 — 第 6 周技能总决战

本周 6 天的技能,今天在一个完整产品里全部串起来:ImageData 读写(Day 39)是数据层;滤镜算法(Day 40 的灰度/反色/亮度/对比度/模糊/锐化)是处理层;Worker 后台处理(Day 41)保证大图调参时 UI 依然丝滑;DPR 适配与坐标换算(Day 29/34)贯穿始终。产品形态:加载图片 → 滤镜链自由叠加 → 滑杆实时调参 → 前后对比查看 → 导出 PNG。做完这个作品,你对 Canvas"像素级操控"的理解就从"会写函数"升级到"能架构一条图像处理管线"。


目录


一、需求分析与技术方案

1.1 产品需求清单

功能 说明 用到的本周技能
加载图片 本地文件选择(input file) createImageBitmap 异步解码(Day 41)
滤镜开关 6 种滤镜独立开关(可叠加) Day 40 全部算法
参数调节 亮度/对比度/模糊半径滑杆实时调 滤镜参数化
实时预览 参数变化后防抖触发重算 Worker 后台处理(Day 41)
前后对比 按住按钮看原图 离屏缓存原图(Day 41)
导出 PNG 下载处理结果 toDataURL(Day 33/35)
缩放适配 大图缩放显示(不缩放处理) drawImage 九参数(Day 33)

1.2 技术选型决策

滤镜叠加方案?
  ❌ 六个滤镜写六个 if 分支互相嵌套 → 组合爆炸,加一个滤镜改十处
  ✅ 滤镜管道:每个滤镜是纯函数,按开关顺序依次 pipe 应用

实时预览方案?
  ❌ 滑杆每动一下同步重算 → 大图卡死 UI
  ✅ 防抖(150ms)+ Worker 后台算 → 拖动丝滑,松手后出图

处理分辨率方案?
  ⚠ 陷阱:处理"显示尺寸"还是"原始尺寸"?
  ✅ 显示用缩放小图(快、预览流畅),导出用原图全量重算(保画质)

二、架构设计:滤镜管道

2.1 整体架构图

┌─────────────────────────────────────────────────────────┐
│                      UI 层(主线程)                      │
│  工具栏:滤镜开关 × 6 + 滑杆 × 3 + 对比按钮 + 导出按钮      │
└──────────────────┬──────────────────────────────────────┘
                   │ 防抖 150ms
                   ▼
┌─────────────────────────────────────────────────────────┐
│                 管道调度层(主线程)                       │
│  buildPipeline(开关状态, 参数) → FilterStep[]            │
│  preview: 小图 ImageData ──Worker──→ 结果 → 贴预览画布    │
└──────────────────┬──────────────────────────────────────┘
                   │ Transferable 零拷贝
                   ▼
┌─────────────────────────────────────────────────────────┐
│                 计算层(Worker 线程)                     │
│  for (step of pipeline) step.fn(imageData, step.params)  │
│  (Day 40 的全部滤镜函数原样运行在此)                      │
└─────────────────────────────────────────────────────────┘

2.2 核心类型定义

/** 滤镜步骤:一个纯函数 + 它的参数(管道的最小单元) */
interface FilterStep {
  name: string;
  params: Record<string, number>;
  fn: (imageData: ImageData, params: Record<string, number>) => void;
}

/** 应用状态:UI 上所有可变的东西 */
interface AppState {
  switches: Record<string, boolean>;      // 滤镜开关:{ grayscale: true, blur: false, ... }
  params: Record<string, Record<string, number>>;  // 参数:{ brightness: { amount: 30 }, ... }
  sourceBitmap: ImageBitmap | null;        // 原图位图
  originalPreview: ImageData | null;       // 预览尺寸的原图快照(滤镜的输入圣域)
}

三、逐模块实现

3.1 滤镜注册表(Worker 侧,Day 40 函数参数化升级)

// filter-worker.ts —— Worker 侧:滤镜注册表 + 消息处理

/**
 * 滤镜注册表:name → 处理函数
 * ⭐ 与 Day 40 的差别:函数签名统一为 (img, params),参数化后滑杆才能调
 */
const FILTERS: Record<string, (img: ImageData, p: Record<string, number>) => void> = {

  /** 灰度:无参数 */
  grayscale: (img) => {
    const d = img.data;
    for (let i = 0; i < d.length; i += 4) {
      const g = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
      d[i] = d[i + 1] = d[i + 2] = g;
    }
  },

  /** 反色:无参数 */
  invert: (img) => {
    const d = img.data;
    for (let i = 0; i < d.length; i += 4) {
      d[i] = 255 - d[i]; d[i + 1] = 255 - d[i + 1]; d[i + 2] = 255 - d[i + 2];
    }
  },

  /** 亮度:amount ∈ [-100, 100](内部映射到 ±255) */
  brightness: (img, p) => {
    const amount = (p.amount ?? 0) * 2.55;   // UI 百分比 → 像素偏移量
    const d = img.data;
    for (let i = 0; i < d.length; i += 4) {
      d[i] += amount; d[i + 1] += amount; d[i + 2] += amount;
    }
  },

  /** 对比度:factor ∈ [0.5, 2],以 128 为轴 */
  contrast: (img, p) => {
    const f = p.factor ?? 1;
    const d = img.data;
    for (let i = 0; i < d.length; i += 4) {
      d[i]     = (d[i]     - 128) * f + 128;
      d[i + 1] = (d[i + 1] - 128) * f + 128;
      d[i + 2] = (d[i + 2] - 128) * f + 128;
    }
  },

  /** 模糊:radius ∈ [1, 4] = 3×3 高斯卷积的遍数 */
  blur: (img, p) => {
    const passes = Math.round(p.radius ?? 1);
    const GAUSS = [1/16, 2/16, 1/16, 2/16, 4/16, 2/16, 1/16, 2/16, 1/16];
    for (let i = 0; i < passes; i++) convolve3x3(img, GAUSS);
  },

  /** 锐化:strength ∈ [0.5, 3] 缩放核强度 */
  sharpen: (img, p) => {
    const s = p.strength ?? 1;
    // 中心 = 1 + 8s,邻居 = -s(s=1 即标准锐化核;和恒为 1)
    convolve3x3(img, [0, -s, 0, -s, 1 + 8 * s, -s, 0, -s, 0]);
  },
};

/** Day 40 的 convolve3x3 原样搬来(略,见 day40 文档 3.3 节) */
declare function convolve3x3(imageData: ImageData, kernel: number[]): void;

// ===== Worker 消息协议 =====
interface WorkerRequest {
  imageData: ImageData;                              // 待处理像素(buffer 已转移)
  steps: { name: string; params: Record<string, number> }[];  // 滤镜管道(只有名字+参数,函数不跨线程!)
}

self.onmessage = (e: MessageEvent) => {
  const { imageData, steps } = e.data as WorkerRequest;

  // ⭐ 管道执行:按序应用每个滤镜(这就是"滤镜链"的全部逻辑)
  for (const step of steps) {
    const fn = FILTERS[step.name];
    if (fn) fn(imageData, step.params);
  }

  (self as unknown as Worker).postMessage({ imageData }, [imageData.data.buffer]);
};

设计要点:跨线程只传“滤镜名 + 参数”,不传函数(函数不可序列化)——滤镜实现住在 Worker 里,主线程只发指令。这就是“策略模式 + 远程执行”的组合。

3.2 主线程:管道调度与防抖

// editor-main.ts —— 主线程逻辑

const canvas = document.querySelector("#board") as HTMLCanvasElement;
const { ctx, cssWidth, cssHeight } = bootCanvas(canvas);   // Day 29

const worker = new Worker("./filter-worker.js", { type: "module" });

const state: AppState = {
  switches: { grayscale: false, invert: false, brightness: false, contrast: false, blur: false, sharpen: false },
  params: {
    brightness: { amount: 20 },
    contrast: { factor: 1.2 },
    blur: { radius: 2 },
    sharpen: { strength: 1 },
  },
  sourceBitmap: null,
  originalPreview: null,
};

/**
 * 根据开关状态构建滤镜管道(调度层的核心)
 * 顺序即 UI 上的固定顺序:先调色(亮度/对比)→ 色彩(灰度/反色)→ 卷积(模糊/锐化)
 */
function buildPipeline(): { name: string; params: Record<string, number> }[] {
  const steps: { name: string; params: Record<string, number> }[] = [];
  if (state.switches.brightness) steps.push({ name: "brightness", params: state.params.brightness });
  if (state.switches.contrast)   steps.push({ name: "contrast",   params: state.params.contrast });
  if (state.switches.grayscale)  steps.push({ name: "grayscale",  params: {} });
  if (state.switches.invert)     steps.push({ name: "invert",     params: {} });
  if (state.switches.blur)       steps.push({ name: "blur",       params: state.params.blur });
  if (state.switches.sharpen)    steps.push({ name: "sharpen",    params: state.params.sharpen });
  return steps;
}

// ===== 防抖调度:滑杆/开关变化后 150ms 才真正触发重算 =====
let debounceTimer: number | null = null;
let busy = false;        // Worker 正在处理中?(防止任务堆积)
let queued = false;      // 处理期间又有新请求?

function scheduleRender(): void {
  if (debounceTimer !== null) clearTimeout(debounceTimer);
  debounceTimer = window.setTimeout(renderPreview, 150);
}

/** 提交预览任务给 Worker */
function renderPreview(): Promise<void> {
  if (!state.originalPreview) return Promise.resolve();

  // ① 从原图快照拷贝一份(滤镜只改副本,原图是圣域)
  const copy = new ImageData(
    new Uint8ClampedArray(state.originalPreview.data),
    state.originalPreview.width,
    state.originalPreview.height,
  );

  // ② 发给 Worker(buffer 转移,零拷贝)
  busy = true;
  worker.postMessage({ imageData: copy, steps: buildPipeline() }, [copy.data.buffer]);
  showProcessing(true);   // UI 显示"处理中"提示
}

// ===== Worker 结果回收 =====
worker.onmessage = (e: MessageEvent) => {
  const { imageData } = e.data as { imageData: ImageData };
  busy = false;
  showProcessing(false);
  putPreview(imageData);   // 贴到预览画布

  if (queued) {            // 处理期间有新参数 → 补一次
    queued = false;
    renderPreview();
  }
};

/** 把处理结果贴到预览画布(物理像素直通道) */
function putPreview(imageData: ImageData): void {
  ctx.setTransform(1, 0, 0, 1, 0, 0);       // 清掉 DPR 变换(putImageData 无视变换,但保险起见)
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.putImageData(imageData, 0, 0);
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);   // 恢复 DPR 基准
}

3.3 图片加载与预览尺寸

/**
 * 加载图片:file input → blob → createImageBitmap(异步解码)
 * 预览用"适配画布的小图",原图留着导出时全量处理
 */
async function loadFile(file: File): Promise<void> {
  state.sourceBitmap = await createImageBitmap(file);   // Day 41:异步解码零卡顿

  // 计算预览尺寸:适配画布(保持宽高比,最大 800×600)
  const scale = Math.min(800 / state.sourceBitmap.width, 600 / state.sourceBitmap.height, 1);
  const pw = Math.round(state.sourceBitmap.width * scale);
  const ph = Math.round(state.sourceBitmap.height * scale);

  // 画到离屏画布 → 抓成 ImageData 快照(预览的输入源)
  const off = new OffscreenCanvas(pw, ph);
  const octx = off.getContext("2d")!;
  octx.drawImage(state.sourceBitmap, 0, 0, pw, ph);     // Day 33:缩放绘制
  state.originalPreview = octx.getImageData(0, 0, pw, ph);

  // 画布尺寸同步成预览尺寸
  canvas.width = pw * dpr;
  canvas.height = ph * dpr;
  canvas.style.width = `${pw}px`;
  canvas.style.height = `${ph}px`;

  putPreview(state.originalPreview);    // 先显示原图
}

document.querySelector("#file")!.addEventListener("change", (e) => {
  const file = (e.target as HTMLInputElement).files?.[0];
  if (file) void loadFile(file);
});

3.4 前后对比与导出

/** 按住"对比"按钮看原图(松开恢复滤镜效果) */
const compareBtn = document.querySelector("#compare")!;
compareBtn.addEventListener("mousedown", () => {
  if (state.originalPreview) putPreview(state.originalPreview);
});
compareBtn.addEventListener("mouseup", () => scheduleRender());   // 松手:重算滤镜版

/**
 * 导出:用【原图全量】重跑管道(不是预览的小图!画质无损)
 * 大图导出可能耗时 → 同样走 Worker + 转圈提示
 */
async function exportPNG(): Promise<void> {
  if (!state.sourceBitmap) return;

  // 原始分辨率 → ImageData
  const off = new OffscreenCanvas(state.sourceBitmap.width, state.sourceBitmap.height);
  const octx = off.getContext("2d")!;
  octx.drawImage(state.sourceBitmap, 0, 0);
  const full = octx.getImageData(0, 0, off.width, off.height);

  // 复用同一个 Worker(临时换个"回调"——导出完成后下载)
  exportResolve = (result: ImageData) => {
    const out = document.createElement("canvas");
    out.width = result.width;
    out.height = result.height;
    out.getContext("2d")!.putImageData(result, 0, 0);

    const link = document.createElement("a");
    link.download = `filtered-${Date.now()}.png`;
    link.href = out.toDataURL("image/png");
    link.click();
  };

  worker.postMessage({ imageData: full, steps: buildPipeline() }, [full.data.buffer]);
}

let exportResolve: ((img: ImageData) => void) | null = null;
// worker.onmessage 里判断当前是预览还是导出(用标记位区分,代码略)

// UI 绑定(开关与滑杆全部走 scheduleRender 防抖入口)
document.querySelectorAll("[data-filter]").forEach((el) => {
  el.addEventListener("change", () => {
    const name = (el as HTMLElement).dataset.filter!;
    state.switches[name] = (el as HTMLInputElement).checked;
    scheduleRender();
  });
});
document.querySelectorAll("[data-param]").forEach((el) => {
  el.addEventListener("input", () => {
    const [filter, key] = (el as HTMLElement).dataset.param!.split(".");
    state.params[filter][key] = Number((el as HTMLInputElement).value);
    scheduleRender();       // ⭐ 滑杆拖动高频触发,全靠防抖兜底
  });
});

四、核心难点攻关

难点 1:滑杆高频触发 vs Worker 单线程

问题:拖动滑杆每秒触发 ~30 次 input 事件,每次都想让 Worker 重算。
     Worker 一次要算 100ms+ → 任务堆积 → 结果乱序到达。

三层防御(本方案的完整答案):
  ① 防抖 150ms:拖动过程中的中间值全部丢弃,只算"停下后"的最终值
  ② busy 标记:Worker 忙时新请求只置 queued = true,不 postMessage
  ③ 快照输入:每次从 originalPreview 拷贝开始 → 乱序到达也不影响正确性
     (最坏情况:白算了几次中间结果,但最终贴上的一定是最新参数的结果)

难点 2:为什么管道顺序是“调色→色彩→卷积”?

// 顺序影响效果!两种顺序的差别:

// 顺序 A(本方案):亮度 → 灰度 → 模糊
brightness(+50) → grayscale → blur
// 先提亮,再转灰(保留了亮度调整的效果),最后模糊

// 顺序 B:灰度 → 亮度 → 模糊
grayscale → brightness(+50) → blur
// 效果几乎一样(亮度作用于灰度值同样有效)

// 但卷积的顺序敏感得多:
blur → sharpen   // 先模糊再锐化:锐化抵消一部分模糊 → "适度柔化"
sharpen → blur   // 先锐化再模糊:锐化出来的边缘立即被抹平 → 基本等于白锐化

// 结论:调色类(亮度/对比)顺序不敏感,卷积类放最后且顺序固定。
// 高级编辑器(Photoshop 图层)让用户自由排序,本项目用固定顺序降低复杂度。

难点 3:ImageData 在主线程和 Worker 之间的“所有权旅行”

// 一次完整旅行的状态变化:
主线程: img.data.buffer ──postMessage([buffer])──→ 转移!
  此刻主线程 img.data.length === 0   // ⚠ 再访问就报错

Worker: 收到完整 buffer → 卷积修改 → postMessage([buffer]) → 转移回!
  此刻 Worker 侧失去引用

主线程: 收到 → putImageData 贴图

// ⚠ 由此产生的纪律:
// ① originalPreview 永远不直接 post(每次先拷贝:new Uint8ClampedArray(...))
// ② post 之后的变量立刻"当作已死",绝不复用

五、验收标准与自测

5.1 功能验收清单

  • [ ] 加载 1000 万像素级大图,解码期间 UI 不冻结(createImageBitmap 生效)
  • [ ] 6 个滤镜独立开关,至少验证 3 种组合叠加效果(如 灰度+对比+锐化)
  • [ ] 拖动“模糊半径”滑杆全程流畅,松手后 150ms 内出图(防抖生效)
  • [ ] Worker 处理期间 UI 显示"处理中",且滑杆依然可以拖动(主线程未阻塞)
  • [ ] 按住对比按钮显示原图,松开恢复滤镜效果
  • [ ] 导出的 PNG 是原始分辨率且滤镜效果完整(不是预览小图放大)
  • [ ] 高分屏(200% 缩放)下预览画面清晰不模糊
  • [ ] 连续快速拖动滑杆 5 秒,最终结果与“停下后单独调一次”完全一致(无乱序)

5.2 代码质量自检

  • [ ] 滤镜函数是纯函数(同输入同输出,无副作用泄漏)
  • [ ] 滤镜实现只在 Worker 文件里出现一次(主线程零复制)
  • [ ] originalPreview 快照从未被滤镜污染
  • [ ] buildPipeline 是唯一的管道组装点(加新滤镜只改注册表 + UI)

六、进阶挑战(选做)

  1. 棕褐色滤镜:按 Day 40 自测题 3 的公式实现 Sepia,加进注册表和 UI。
  2. 马赛克滤镜:分块取平均色(Day 40 自测题 4),块大小做成滑杆参数。
  3. 热成像伪彩:先灰度化,再把亮度映射到“蓝→青→绿→黄→红”色带(提示:写一个 grayToHeat(gray) 查表函数)——工业测温可视化的经典效果。
  4. 撤销参数:记录最近 10 组参数快照,Ctrl+Z 回退上一次调参。
  5. 分块处理进度条:把大图切成横条分批处理,每批回报进度——导出 4K 图时显示进度条而不是干等。

七、常见坑点速查

# 症状 一句话解法
1 滑杆直接触发重算 拖动卡顿、任务堆积 防抖 150ms + busy/queued 标记
2 postMessage 不传 buffer 每次传输卡 10ms+ [buffer] Transferable
3 转移后访问原 buffer length=0 报错 post 后变量当已死
4 Worker 里写滤镜实现复制了一份到主线程 两处代码漂移 实现只住 Worker,主线程只发名字+参数
5 导出用预览小图 导出图是糊的 导出走原图全量重算
6 putImageData 前忘了坐标系 贴图错位 物理像素直通道(Day 39 惯例)
7 滤镜改了 originalPreview 切换开关效果叠加 每次从快照拷贝开始
8 卷积顺序随手排 锐化后模糊 = 白干 管道顺序固定:调色→色彩→卷积
9 bitmap 不 close 反复加载图片内存暴涨 换图时 oldBitmap.close()
10 Worker 未模块化却用 import Worker 启动报错 new Worker(url, { type: "module" })

八、周总结与知识图谱

8.1 第 6 周技能全景

第 6 周 Canvas 变换与像素(Day 36-42)
├── Day 36 save/restore 状态栈
│   ├── ctx 单份状态、栈式管理(LIFO)
│   ├── 管理范围:样式 + 行为 + 变换矩阵 + 裁剪
│   └── 选型:变换必用栈 / 热路径单属性直接赋值
├── Day 37 变换三件套
│   ├── 桌布模型:动坐标系不动图形
│   ├── rotate 弧度制、顺时针为正
│   ├── 顺序不可交换(从后往前作用)
│   └── 中心变换三步曲模板
├── Day 38 变换矩阵
│   ├── 六参数 a b c d e f、set/transform
│   ├── 场景模型 screen = zoom·scene + pan
│   └── 以鼠标为中心缩放(pan' = mouse - zoom'·scene)
├── Day 39 ImageData 基础
│   ├── Uint8ClampedArray、(y·w+x)×4 定位
│   ├── put/get 无视变换(物理像素直通道)
│   └── GPU 回读开销意识
├── Day 40 卷积与滤镜
│   ├── 灰度加权 0.299/0.587/0.114
│   ├── 3×3 卷积模板(快照→跳边→9邻累加)
│   └── 模糊/锐化/边缘全家福 + 可分离优化
├── Day 41 Offscreen 与性能
│   ├── 离屏缓存:画一次贴万次
│   ├── createImageBitmap 异步解码
│   └── Worker 像素处理 + Transferable 零拷贝
└── Day 42 BOSS:滤镜编辑器 🎯
    ├── 滤镜管道(注册表 + 纯函数)
    ├── 防抖 + busy/queued 调度
    └── 预览小图 / 导出原图双轨制

8.2 本周认知升级

之前(学本周前) 现在(学完本周后)
旋转图形 = 手算三角函数坐标 rotate 坐标系,图形按局部坐标画
缩放画面 = 重算所有元素位置 一个 viewport 矩阵搞定,拾取用逆变换
滤镜 = 神秘的图像库黑盒 两层 for + 一个 3×3 权重表,自己写
卡了 = 加 try hard / 换电脑 测量 → 离屏缓存 / Worker / 算法优化
Canvas 计算= 主线程唯一选择 像素管线天然可搬进 Worker

8.3 下周预告(第 7 周:Canvas 进阶)

变换和像素都是“单点技能”,下周做综合集成:裁剪 clip(雷达图/仪表盘的扇形蒙版)、碰撞检测(图元拖拽防重叠)、脏矩形优化(只重绘变化区域)、多层画布架构(静态层/动态层/交互层分离)——BOSS 战是流程图/拓扑图编辑器:节点拖拽、贝塞尔连线、框选、删除——工业组态编辑器的雏形,也是阶段 2 大屏项目最直接的前置实战。

本周毕业标准:滤镜编辑器完成 6 滤镜 + 防抖预览 + 原图导出;能独立复写 buildPipeline 调度层与 Worker 消息协议(不看文档)。

评论