#88 制作一款 Figma 中查看图片直方图的插件

插件名:Image Histogram

#88 制作一款 Figma 中查看图片直方图的插件

Image Histogram 插件可以让你在 Figma 中查看图片的直方图,以及复制粘贴曝光、对比度等图片调整参数。

Image Histogram | Figma
在 Figma 查看图片直方图 支持彩色直方图和亮度直方图;调整图片实时预览;复制粘贴图片调整参数;两种直方图样式;免费; Show Image Histogram in Figma Supports RGB and luminosity histogram;Real-time preview of image adjustments;Copy and paste image adjustments;Two histogram styles;Free;

之前我还做了一个在 Figma 中给位图添加描边的插件

这个插件应该无需详细说明,下面分享的是制作这款插件的一些历程。对于开发大佬们应该没什么价值,权当是我自己的一个梳理复盘。

需求来源

最开始的痛点在于 Figma 无法复制粘贴甚至无法看到图片调整的具体数值。

Figma 中的图片调整工具截图

2025 年了,调整完连个「重置」按钮也没有,归零还有手动一一滑动每个滑块。我知道 Figma 的产品重点不是图像编辑。但这不太符合 Figma 以往的设计调性。以前的 Figma 在迭代复制粘贴时展示了足够的耐心和细心。而在这种边缘角落便和常规互联网公司没什么区别——又不是不能用。

复制粘贴数值这点,已经有一些图像编辑插件可以实现(搜索 Image edit 关键词)。那一晚灵光突现,想看看有没有人在 Figma 做直方图。一番搜索后发现貌似没有,是啊毕竟对 UI 设计没什么用。

但是感觉挺有意思的!

效果

一组不同亮度的风景图片
一组不同曝光侧重的动物图片

拆分需求

插件有两个核心需求:

  1. 展示选中图片直方图
  2. 复制/粘贴图片调整参数

以此拓展:

  1. 展示选中图片直方图:
    1. 【高】展示彩色和亮度(灰度)直方图
      1. 【中】并可切换只看某通道的直方图;
    2. 【中】调整图片时,直方图实时更新;
  2. 复制/粘贴图片调整参数:
    1. 【中】查看参数数值;
    2. 【高】参数能被重置;
  3. 插件通用需求:
    1. 【中】英文本地化;
    2. 【低】浅色/深色主题模式适配;

这里用 高中低 代表了需求优先级。目前的 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 的亮度公式我也试过,日后看看需不需要修改回来或者作为切换项。

Grayscale - Wikipedia

备菜完毕。

展示直方图

这次 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 界面的「直方图」,去量化看到界面的视觉体验。此时只是单纯地去列出像素数值没有意义,可能还需要考虑颜色之间的「间距」……总之这也是个有意思的话题,值得长期探索。

订阅 Design Scenes

发布最新文章时,会以邮件通知你
zelda@link.com
订阅