基于 node-vibrant 获取图片的“调色板”信息
toolnodejs, webp, minitool

基于 node-vibrant 获取图片的“调色板”信息

Published On
| 更新于
Updated On
(C) unsplash
12分钟阅读a7c837d0
概述:使用脚本工具获取图片的主色调配色方案,并输出到指定文件

背景

本站博客的架构中,博文来源包括了远程 CMS 平台(Sanity),除了用作博文的存储平台,也可以用作为图床,而该平台返回的图床信息中,包括了"palette"(调色板) 字段,其下包括从图片提取的几种主色调,以及与之相配的前景色,json 结构如下:

sanity-palette-info

而近期对博客重构升级中,吾辈拟对本地博文(指 markdown 文档存储在博客源码同目录下)也支持图片封面 + 提取配套前景背景色方案

正文

先抬出下面几个必要链接,方便你的跳转:

吾辈一开始本地安装导入 node-vibrant@latest, 再编写额外代码,用于读取指定路径下图片,不过很快遇到报错,即不能解析比如 webp 格式的图片(的配色方案),

而后再网络搜索,便找到上面的 Issue 链接,该链接下的评论回复给出重要信息和一些代码段分享,总而言之,依然可以继续使用 node-vibrant 这个程序,但是它本身为了最大兼容性只支持有限的图片格式,但是,允许开发者自行拓展

node-vibrant is designed to be extendable. Image format support is provided via ImageClasss. One can implement his/her own ImageClass and use it by set Vibrant.DefaultOpts.ImageClass = YourCustomImageClass.

node-vibrant 的设计是可扩展的。图像格式支持通过 ImageClass 提供。用户可以实现自己的 ImageClass 并通过设置 Vibrant.DefaultOpts.ImageClass = YourCustomImageClass 来使用它。

而如何拓展可参考 Issue 下这个开发者评论, 当然社区还是有已发布的 NPM 包,吾辈最终选择了安装其他开发者基于 node-vibrant 的改动版本

另外,需要注意,node-vibrant 默认只提供背景色的配色方案,即如果需要完整的配色方案(即前景色 + 背景色),需要额外的算法支持。

下面给出该算法分享:

shouldUseLightText.js
/**
 * 根据背景色 RGB 值判断前景色是否应该使用白色
 * 返回 true 表示应该使用白色,false 表示应该使用黑色
 * @param r - 红色通道值 (0-255)
 * @param g - 绿色通道值 (0-255)
 * @param b - 蓝色通道值 (0-255)
 */
export function shouldUseLightText(r, g, b) {
  // 将 RGB 值转换为相对亮度
  // 根据 WCAG 2.0 规范:https://www.w3.org/TR/WCAG20/#relativeluminancedef
  const toSRGB = (x) => {
    x = x / 255;
    return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
  };
  const sR = toSRGB(r);
  const sG = toSRGB(g);
  const sB = toSRGB(b);
  // 计算相对亮度 L
  const L = 0.2126 * sR + 0.7152 * sG + 0.0722 * sB;
  // 如果亮度小于 0.5,使用白色文本
  // 如果亮度大于等于 0.5,使用黑色文本
  return L < 0.5;
}

完整可用代码段

当然的,如果读者某一天也有类似的开发需求,吾辈在下面贴出先前自用的完整代码,能够满足一定的使用场景。(注意,下面代码中,相关目录路径需要复制后自行替换)

支持显式声明 tagFilterMode 字段,开启所谓的“增量更新”模式,免去每次解析重复的图片。

开源地址

tool-opt.mjs
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import Vibrant from "@behold/sharp-vibrant";
import { shouldUseLightText } from "./_shouldUseLightText.mjs";

const reg = /\.(?:webp|jpg|jpeg|png)$/; // ⚠️ avif not surpport at now.
const colorTypes = ["Vibrant", "LightVibrant", "DarkVibrant", "Muted", "LightMuted", "DarkMuted"];

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

invoke({
  imgPath: path.join(__dirname, "<path/to/you-imgs-file>"),
  outFile: path.join(__dirname, "<path/to/your-json-file>"),
  filterMode: true,
  jsonMiniStyle: true,
});

/**
 * @param {{filterMode: boolean, imgPath: string, outFile: string}}
 * > filterMode : ⚠️ 支持过滤已经处理过的图片文件 | 通过该字段支持
 * 🚩 flagMut = !filterMode || initFlag; //true-全覆盖模式
 */
async function invoke({
  imgPath,
  outFile,
  filterMode,
  jsonMiniStyle = false,
}) {
  if (!fs.existsSync(imgPath))
    throw new Error(`❌ 请确认图片文件夹合法:${imgPath}\n`);

  /** @type Promise[] */
  const ps = [];
  const resMap = Object.create(null);

  const { initFlag } = validatorOutDir$Eff(outFile);
  const flagOverrides = initFlag || !filterMode;

  // note: filterMode = false 情况下无必要再去读已有数据,后续总会全覆盖
  const {
    existingData,
    alreadyKeysToImg,
  } = getAlreadyDataFromOutFile({ outFile: flagOverrides ? null : outFile });

  const needHdlThisImg = (imgName) => {
    /** 业务出发,该函数改为非纯函数,利用闭包访问外部 filterMode, alreadyKeys */
    if (flagOverrides)
      return reg.test(imgName);
    return reg.test(imgName) && !alreadyKeysToImg.includes(imgName);
  };

  const getFullData = (resMap) => {
    if (!flagOverrides) {
      return ({
        fullData: { ...existingData, ...resMap },
        modeTextZh: "增量更新模式",
      });
    }
    return ({
      fullData: resMap,
      modeTextZh: "全覆盖模式",
    });
  };

  const mutJsonStringify = (data) => {
    if (typeof jsonMiniStyle === "boolean" && jsonMiniStyle)
      return JSON.stringify(data);
    return JSON.stringify(data, null, 2);
  };

  const {
    imgNameList,
    imgFullpathList,
  } = getValidImgFullPathListOpt(imgPath, needHdlThisImg);

  if (imgNameList.length === 0) {
    console.log("🟪 静默结束,本次未有图片被处理解析");
    return;
  }

  for (const onePath of imgFullpathList) {
    ps.push(Vibrant.from(onePath).getPalette());
  }

  try {
    const res = await Promise.all(ps);

    for (const idx in res) {
      const key = imgNameList[idx];
      const item = res[idx];
      const slotOne = hdlPaletteMutate(item);

      resMap[key] = {
        ...item,
        ...slotOne,
      };
    }

    const { fullData, modeTextZh } = getFullData(resMap);
    fs.writeFileSync(outFile, mutJsonStringify(fullData));
    console.log(`${modeTextZh}: 处理${imgNameList.length}张图片文件:`, ...imgNameList);
    console.log("🟪 your output file:", outFile);
    console.log("🟩 done.");
  }
  catch (e) {
    console.error("❌ 异常原因中断退出", e);
  }
}

/** 带副作用的函数,校验不通过即直接创建相关目录 */
function validatorOutDir$Eff(outFile) {
  const outDir = path.dirname(outFile);
  const cond = fs.existsSync(outDir);
  let initFlag = false;
  if (!cond) {
    fs.mkdirSync(outDir, { recursive: true });
    console.log(`🟩 已自动创建目录: \n${outDir}`);
  }
  if (!cond || !fs.existsSync(outFile)) {
    fs.writeFileSync(outFile, JSON.stringify({}));
    console.log(`🟩 已自动创建文件: \n${outFile}`);
    initFlag = true;
  }
  return { initFlag };
}

function getAlreadyDataFromOutFile({ outFile }) {
  if (!outFile) {
    return ({
      existingData: {},
      alreadyKeysToImg: [],
    });
  }

  try {
    const fileContent = fs.readFileSync(outFile, "utf8");
    const existingData = JSON.parse(fileContent);
    const alreadyKeysToImg = Object.keys(existingData);
    return ({
      existingData,
      alreadyKeysToImg,
    });
  }
  catch (e) {
    console.log("❌ 读取文件或解析发生错误!", outFile);
    throw (e);
  }
}

/** 一开始即过滤掉已处理过文件 */
function getValidImgFullPathListOpt(imgPath, needHdlThisImg) {
  const imgNameList = [];
  const imgFullpathList = [];
  const filterFunc = needHdlThisImg ?? reg.test;

  fs
    .readdirSync(imgPath)
    .forEach((it) => {
      // opt: 原本的先 filter,再 forEach ,两遍遍历
      if (!filterFunc(it))
        return;
      imgNameList.push(it);
      imgFullpathList.push(path.join(imgPath, it));
    });

  return {
    imgNameList,
    imgFullpathList,
  };
}

/** sideEffect mutate slotOne */
function hdlPaletteMutate(item) {
  const slotOne = {
    palette4Forground: {},
  };
  colorTypes.forEach((type) => {
    if (item.palette[type]) {
      const backgroundColor = item.palette[type].rgb; // [23,23,23]
      slotOne.palette4Forground[type]
        = shouldUseLightText(...backgroundColor) ? "#FFF" : "#000";
    }
  });
  return slotOne;
}
tool.js@deprecated

基于 node-vibrant 获取图片的“调色板”信息

https://infen.cc/loc-blog/26_get-pic-palette-by-node-vibrant[Copy]
本文作者
Helen4i, TC
创建/发布于
Published On
更新/发布于
Updated On
许可协议
CC BY-NC-SA 4.0

转载或引用本文时请遵守“署名-非商业性使用-相同方式共享 4.0 国际”许可协议,注明出处、不得用于商业用途!分发衍生作品时必须采用相同的许可协议。