#88 制作一款 Figma 中查看图片直方图的插件
插件名:Image Histogram
Image Histogram 插件可以让你在 Figma 中查看图片的直方图,以及复制粘贴曝光、对比度等图片调整参数。
之前我还做了一个在 Figma 中给位图添加描边的插件。
这个插件应该无需详细说明,下面分享的是制作这款插件的一些历程。对于开发大佬们应该没什么价值,权当是我自己的一个梳理复盘。
需求来源
最开始的痛点在于 Figma 无法复制粘贴甚至无法看到图片调整的具体数值。
2025 年了,调整完连个「重置」按钮也没有,归零还有手动一一滑动每个滑块。我知道 Figma 的产品重点不是图像编辑。但这不太符合 Figma 以往的设计调性。以前的 Figma 在迭代复制粘贴时展示了足够的耐心和细心。而在这种边缘角落便和常规互联网公司没什么区别——又不是不能用。
复制粘贴数值这点,已经有一些图像编辑插件可以实现(搜索 Image edit 关键词)。那一晚灵光突现,想看看有没有人在 Figma 做直方图。一番搜索后发现貌似没有,是啊毕竟对 UI 设计没什么用。
但是感觉挺有意思的!
效果
拆分需求
插件有两个核心需求:
- 展示选中图片直方图
- 复制/粘贴图片调整参数
以此拓展:
- 展示选中图片直方图:
- 【高】展示彩色和亮度(灰度)直方图
- 【中】并可切换只看某通道的直方图;
- 【中】调整图片时,直方图实时更新;
- 【高】展示彩色和亮度(灰度)直方图
- 复制/粘贴图片调整参数:
- 【中】查看参数数值;
- 【高】参数能被重置;
- 插件通用需求:
- 【中】英文本地化;
- 【低】浅色/深色主题模式适配;
这里用 高中低
代表了需求优先级。目前的 AI 辅助编程,最好还是模块化去实现各个需求,方便测试、纠错,并且可以在每个实现需求的关键节点内部迭代版本。下面是我在本次开发中的迭代记录:
0.1: 初步获取直方图各个通道数据;
0.2: 初步显示直方图;
0.3: 完善直方图细节,调节准确性;
0.4: 可以实时获取调整后的图片直方图;
0.5: 增加滤镜参数展示,复制、粘贴功能;
0.6: 初步完成 UI 适配;
0.7: 更改通道展示逻辑,可自由配置通道组合;
0.8: 实现直方图样式切换;
0.9: 修复Figma 新 API 导致的 <canvas> DPI 显示错误;
1.0: 上线送审版本:添加翻译,添加对多图层的识别逻辑;
就像我们熟悉的大多产品流程一样,需求并非一成不变,根据测试和使用体验需要不断调整。
学习学习
我不是开发,所以我在 Cursor 左侧配置了 Continue 的tab,拿 DeepSeek 代码和对话模型去验证和理解代码知识。这样不占用 Cursor 额度,便宜量大,缺点是上下文长度 128K。
拿到颜色
Figma 中涉及图片处理,基本都需要解码图像。上次做描边插件也是,解码后会 Figma 会返回该图像的带有 RGBA 的一维数组(A 是 alpha 透明度信息),这样每四个字节一循环便可拿到所有像素 RGB 信息。
一个 2x2 的图像格式为:
[R0, G0, B0, A0, R1, G1, B1, A1, R2, G2, B2, A2, R3, G3, B3, A3]
至于亮度计算采用了 BT.709 标准,比 BT.601 更适合现代屏幕,增加了绿色权重并进一步降低蓝色权重。
电脑中的图像输出 RGB 值大多已经经过伽马校正的非线性数值(也就是 R G B 后面带个撇号),所以直接拿来做乘法。当然 BT.601 的亮度公式我也试过,日后看看需不需要修改回来或者作为切换项。
备菜完毕。
展示直方图
这次 code.ts
主要用于处理图片调整参数,所以可以歇歇了,直方图展示需要再 ui.html
中新建 <canvas>
来展示。按照我的需求,AI 的解决方案是预先绘制 4 个 canvas:
- 主 canvas:用于展示最终直方图;
- offscreenCanvas:不可见。用于混合(Blend)各通道直方图,再绘制到主 canvas;
- channelCanvas:不可见。用于绘制各通道直方图,再传给 offscreenCanvas;
- tempCanvas:不可见。用于处理颜色数据(上述内容),包括加入图片调整参数后的改动,再传到 channelCanvas;
从下到上执行。
默认是右侧 style 2 的样式。描边 + 半透明填充的曲线。为了实现左侧这种,启用了上面提到的 offscreenCanvas 以 plus lighter 模式混合各通道重叠处,也就是:
- 红 + 绿 = 黄
- 绿 + 蓝 = 青
- 蓝 + 红 = 洋红
- 红 + 绿 + 蓝 = 白
Plus lighter 需要提高曲线的填充透明度,索性设置成了两套样式。
const histogramStyles = {
1: {
red: { stroke: 'rgba(255, 0, 0, 0.8)', fill: 'rgba(255, 0, 0)' },
green: { stroke: 'rgba(0, 255, 0, 0.8)', fill: 'rgba(0, 255, 0)' },
blue: { stroke: 'rgba(0, 0, 255, 0.8)', fill: 'rgba(0, 0, 255)' },
luminosity: { stroke: 'rgba(255, 255, 255, 0.8)', fill: 'rgba(255, 255, 255, 0.4)' }
},
2: {
red: { stroke: 'rgba(255, 0, 0, 0.9)', fill: 'rgba(255, 0, 0, 0.2)' },
green: { stroke: 'rgba(0, 255, 0, 0.9)', fill: 'rgba(0, 255, 0, 0.2)' },
blue: { stroke: 'rgba(0, 0, 255, 0.9)', fill: 'rgba(0, 0, 255, 0.2)' },
luminosity: { stroke: 'rgba(255, 255, 255, 0.9)', fill: 'rgba(255, 255, 255, 0.2)' }
}
};
曲线精度没打算像专业软件一样,变成凹凸不平的柱状图,所以用三次贝塞尔曲线来连接各个点。不过测试时,在某些极端状况下,曲线会变得如锯齿般难以解读(无意义的锯齿),通常是距离较近的 X 轴上有着很高的 Y 轴落差。所以这里面又加入了高斯滤波,减少局部凸起的「噪声」对整体直方图的影响。
还在摘 OpenCV 的桃。
图像调整参数
Figma 中的一切都是由 node 构成的,所以寻找哪个元素就像「山里有座庙,庙里有个老和尚」一样层层寻找判断。这次要找的是当前选中的图层,Figma 有专门的接口 SceneNode,我们直接开始声明:
let selectedNode: SceneNode | null = null;
//...
//如果当前选中的不是矩形节点,输出警告信息
if (!selectedNode || selectedNode.type !== 'RECTANGLE') {
console.log('No selection or not a rectangle');
figma.ui.postMessage({ type: 'no-selection' });
return;
}
//看看 Fill 有无内容,输出警告信息。.fills.length 也可用于选中特定第 N 个图层
if (!selectedNode.fills || !Array.isArray(selectedNode.fills) || selectedNode.fills.length === 0) {
console.log('No fills found');
figma.ui.postMessage({ type: 'no-image' });
return;
}
// 找到最上方的未隐藏的图片填充(fill.visible !== false)
const visibleImageFill = selectedNode.fills
.slice()
.reverse()
.find(fill => fill.type === 'IMAGE' && fill.visible !== false);
// 通过 .filters 拿到调整参数
const filters = visibleImageFill.filters || {};
// 发给 ui.html 以调用
figma.ui.postMessage({
type: 'process-image',
data: Array.from(bytes),
filters: filters
});
// 这里省去了 .on 用来监视属性变化的代码
感觉是一件挺简单的事但是 Figma 的文档搞得特别抽象,生怕多写一个单词就会扣钱一样。配合 REST API Get file 会更好理解一些,比如一张图片节点其实这样的(截取):
{
"id": "18:94",
"name": "image 2",
"type": "RECTANGLE",
"fills": [
{
"blendMode": "NORMAL",
"type": "IMAGE",
"scaleMode": "FILL",
"imageRef": "*ref*",
"filters": {
"exposure": 0.49000000953674316,
"contrast": 0.12999999523162842,
"saturation": -0.6000000238418579,
"temperature": 0.4399999976158142
}
},
{
"blendMode": "NORMAL",
"visible": false,
"type": "IMAGE",
"scaleMode": "FILL",
"imageRef": "*ref*"
}
],
可以看到,只有图层是否隐藏由 “visible
”控制,而且只有调整参数不是 0 了,才会有 filters
项。这些调整参数都是取 -1 到 1 区间的值,中间为 0,即默认未调整状态。顺便吐槽一句,Figma 在面向用户时管这个叫 adjustments,实际上用的还是 filter 命名。
到了复制粘贴操作,我采用了 figma.clientStorage 来存储复制后参数,相当于本地的缓存数据,清除后会消失。很适合存储这种临时复制粘贴的数据,又不涉及隐私。
储存形式是按照一定顺序和格式生成 FIA4P[0, 0, 0, 0, 0, 0, 0]
这样的字段。由于 figma.clientStorage 能储存很多类型变量所以保险起见还是加了标识符,毕竟 AI 很擅长写正则。
// 声明
const filterOrder = ['exposure', 'contrast', 'saturation', 'temperature', 'tint', 'highlights', 'shadows'];
const values = filterOrder.map(key => currentFilters[key] || 0);
const text = `FIA4P[${values.map(v => v.toFixed(2)).join(',')}]`;
// 保存到 clientStorage 的消息传递
parent.postMessage({
pluginMessage: {
type: 'save-filters',
filterText: text
}
}, '*');
// 同上,取出
parent.postMessage({
pluginMessage: {
type: 'load-filters'
}
}, '*');
// (前面有 msg.type 类型判断) 保存成功后在控制台和 Figma 底部输出提示
await figma.clientStorage.setAsync('latest_filters', msg.filterText);
console.log('Filters saved:', msg.filterText);
figma.ui.postMessage({ type: 'save-success' });
// (前面有 msg.type 类型判断) 取出参数与提示
const filterText = await figma.clientStorage.getAsync('latest_filters');
console.log('Loaded filters:', filterText);
figma.ui.postMessage({
type: 'load-filters-result',
filterText: filterText
});
像 .fills
貌似直接带了覆写功能,所以遍历找到相应参数可以直接修改:
const newFills = selectedNode.fills.map(f => {
if (f.type === 'IMAGE') {
return {
...f, //浅拷贝所有属性
filters: msg.filters //更新 filters 属性
};
}
return f; //如果填充不是图像,则返回原始填充;
});
// 更新节点的 fills
selectedNode.fills = newFills;
// 更新直方图显示
await updateHistogram();
参数重置同理,直接将全部 filter 参数赋予 0 即可。
到这里,插件的两项功能已经实现的七七八八了。
其他通用需求
Figma 中要想实现深色和浅色主题模式切换,需要用到官方提供的一套 color tokens。告诉 AI 这个网址让他们自己看去吧:https://www.figma.com/plugin-docs/css-variables/。你可以拉到最下方手动微调颜色。
中英翻译一般是临近上线版本再去做,这类需求 AI 基本也会一遍成功。找到了声明的 translations 对象,里面中英内容可自行校对和调整。
闲暇时间可以把下面商店页信息定下来:
- 插件名称
- 插件描述
- 详情介绍
- 至少一张 1080p 的 banner
- 插件图标,128 x 128
- (如果 banner 位没有,还得准备)插件使用录屏,为审核做准备
完成上线版本后可以第一时间送审。因为排队和时差原因,至少 24h 后才会收到邮件通知让你上传录屏或通知核对结果。Figma 的网页环境基本不会出什么岔子。着急的话尽量周一等早些时候提审,因为那边双休。
总结
由于这次算法不复杂,所以成型时间很快。再一次感受到,AI 无法实现我的需求时,大概率是我自己没有表述清楚。日常人与人对话中自带了很多「上下文」,很多时候会出自本能地消歧义。AI (Claude-3.5-sonnet) 显然尚未达到这个水平,但是理解地越来越好了。所以一般对话 8-10 轮没有实现需求时,我便会读取需求前的存档重新再来。下次也会试试 .cursorrules
会带来什么改变。
在了解照片直方图的过程中,我也在思考有没有一种适用 UI 界面的「直方图」,去量化看到界面的视觉体验。此时只是单纯地去列出像素数值没有意义,可能还需要考虑颜色之间的「间距」……总之这也是个有意思的话题,值得长期探索。