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)
六、进阶挑战(选做)
- 棕褐色滤镜:按 Day 40 自测题 3 的公式实现 Sepia,加进注册表和 UI。
- 马赛克滤镜:分块取平均色(Day 40 自测题 4),块大小做成滑杆参数。
- 热成像伪彩:先灰度化,再把亮度映射到“蓝→青→绿→黄→红”色带(提示:写一个
grayToHeat(gray)查表函数)——工业测温可视化的经典效果。 - 撤销参数:记录最近 10 组参数快照,Ctrl+Z 回退上一次调参。
- 分块处理进度条:把大图切成横条分批处理,每批回报进度——导出 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 消息协议(不看文档)。