本文首发自 微盟技术中心 微信公众平台~
一、前言
主题色,简而言之,是界面设计中被用作基础配色方案的主要颜色。它是在设计中明确定义的一种核心色彩,用于塑造界面的整体氛围和风格。通过恰当选择和运用主题色,可以 提升界面的可视性和易用性,从而大幅地提升用户体验。提到图像提取主题色,⼤家可能会想到 OpenCV 这种专业的计算机视觉库,其实Javascript 这种轻量级的 脚本语⾔也是可以被⽤来实现该功能。并且相较于其他语⾔,由于其实时性和动态性以及浏览器的原生支持,JS 具有更好的动态响应能⼒以及定制化能力。
二、背景
目前微盟 CRM 会员模块主要支持两种类型的会员卡,分别是等级会员卡以及权益会员卡。商户可以根据不同的应用场景去创建不同的会员方案(成长值等级类型、消费行为等级类型、权益卡以及付费会员),以及对它们配置、管理。满足丰富的业务场景,能帮助商家针对行业对会员进行个性化运营,帮助商家给会员提供更好的服务,提升会员的黏性和忠诚度。 并且每⼀套等级/权益卡的卡面外观,包含了勋章图案、卡面背景、自定义图片、⽂字颜色和背景配色,商户可以对其进行自定义搭配用以塑造界面的整体氛围和风格,提升整体的视觉体验。等级卡装修页面等级卡背景色设置 权益卡设置当商家配置好背景/卡面图片后,其需要手动配置背景/卡面主题色,可能需要进行多次尝试才能找到最合适的配色方案。这无疑增加了配置成本。此外,可供选择的主题色为固定的几个色板,这导致在设计页面时受到一定的局限性。为了解决这一问题,我们考虑在商户配置好图片后自动生成与其图片相匹配的主题色并自动应用,从而提升整体观感。
三、需求分析
解决该问题最重要的便是获取图片的主题色啦,那么我们该如何去获取到一张图片的主题色呢?市场上常用的提取主题色的方案主要为以下几种:
1.基于平均色的方案:这种方法计算图像中所有像素的RGB值的平均值,然后将这个平均值作为主题色。这种方法简单直接,但可能无法捕捉到图像中的主要色调。2.颜色直方图:将图像的颜色分布表示为颜色直方图,然后选择直方图中的峰值作为主题色。这种方法对于多种颜色的图像效果较好,但总体效果较差3.Opencv: 使用 OpenCV 这个强大的计算机视觉库来加载图像,然后使用颜色空间转换和阈值处理来提取特定颜色的部分。但其实 Javascript 也可以被用来实现该功能,并且相较于其他方案,Js 处理的好处如下: 1.客户端控制:无需后端服务器支持,不用处理图像上传/下载等逻辑。2.响应性:在处理尺寸较小的图片(CRM会员卡背景图片限制1M以内)时,具有更好的响应性。3.交互性:可以直接在前端图像上实现交互,例如用户点击图片上的区域来提取特定颜色,或者通过滑块来调整颜色提取的敏感度。
四、需求分析
简而言之,我们的技术方案主要为以下几个步骤:
1.加载图像数据: 使用 Canvas 绘制图像并获取像素数据。2.数据预处理: 对像素数据进行降噪/缩放/去皮等定制化处理。3.生成主题色:采用切割算法(中位切分/八叉树)进行颜色提取。接下去我们会一步一步讲解具体的实现步骤。
1、加载图像数据
前端开发者对于 Canvas 这个元素应该是再熟悉不过了,我们通过 Canvas 的像素渲染的特性,很轻松的就能获取到图片上的像素数据。但需要注意的是需要使用 IE8及之后版本哦。 首先我们需要通过创建一个 Canvas 元素并调用 Canvas 上下文的 DrawImage 方法将图片绘制到 Canvas 上,再通过 GetImageData 方法获取到图像数据,即ImageData 对象。并且我们使用了 Web Workers 来对 Canvas 进行离屏渲染,使其在后台线程中处理图像,防止其阻塞主线程的执行。现在我们已经获取到所有像素数据了,接下来我们便需要对其进行预处理,为我们之后的计算打好基础。2、数据预处理
2.1 降噪
这里的降噪不是指声音的降噪,而是图像处理中的一个专业术语,简单来说是为了去除图像中的干扰。常见的图像降噪处理有高斯滤波、均值滤波、中值滤波等方式。我们使用高斯滤波(Gaussian Filter)来解释其工作原理,滤波器本质是一种对图像的卷积(image convolutions)。什么?你说你不知道啥是卷积?那你知道图像的模糊和锐化吧?那就是一个卷积。用过P图软件的魔术棒抠图吗?没错那也是卷积。用手机自拍开过美颜加过滤镜吗?恭喜你!这还是卷积。卷积无处不在,而且也很好理解,用数学上的术语来表示卷积是对两个相同维度的矩阵进行逐元素相乘并求和。如果把图片当做一个大矩阵,那么对图片进行模糊、锐化、边缘检测等处理的功能就是一个小矩阵我们称之为核(kernel),通常来说核的大小是MxM(M为奇数)如3x3,这样可以确保其有一个核心坐标(x, y)。接着把核心坐标放在图像的起始坐标上,将他们重叠区域的元素进行逐个相乘后求和并将计算结果输出到和当前核心坐标相同的图像坐标上,按从左往右从上往下的顺序平滑核心坐标重复此步骤。
弄明白卷积后我们回过头来讲高斯滤波就容易多了,万变不离其宗,高斯滤波其实就是创造一个符合高斯分布的卷积核并对图像进行卷积计算(对像素的R、G、B值分别计算得出结果),详细步骤不在这里过多阐述,来看下具体的运行效果。 经过高斯滤波处理之后,图片看起来变模糊了。这就是像素被平滑处理之后的效果,为了减少之后计算的复杂度。
2.2 去皮
经过降噪处理后,我们还需要剔除一部分边界颜色我们称之为去皮。因为有些配图是带有纯色背景色的比如白色,大面积的白色或黑色会影响统计结果,并且造成统计性能的下降。在实际运行过程中会发现有些看似是白色的背景实际会有一定的色差。因此我们并不是简单的对颜色值为(255, 255, 255)的像素去除,而是选取一定的范围。这个范围不能过大,且尽量贴近黑、白色的边界。如果我们选取RGB值小于(5, 5, 5)或大于(250, 250, 250)的像素进行去除的话,代码实现如下:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
function convertToPixelsArray(imgData) { const data = imgData.data; const pixels = []; const [min, max] = [5, 250]; //像素点RGB值不在此范围内的进行过滤 范围可选 for (let i = 0; i < data.length; i += 4) { const r = data[i]; const g = data[i + 1]; const b = data[i + 2]; // rgb值都在[5, 250]范围内,则不过滤 if (!(Math.min(r, g, b) >= max || Math.max(r, g, b) <= min)) { // 过滤透明度(可选) pixels.push([data[i], data[i + 1], data[i + 2]]); } } return pixels;}
3、生成主题色 在我们的项目中,我们主要采用了两种算法:中位切分法和八叉树。这些算法被用于不同的情境,以满足不同的图像处理需求。对于那些需要快速获取主题色,并且对图像细节不太关心的场景,我们选择了中位切分法。这个简单且高效的算法能够帮助用户快速批量地获取图片的主题色。在这种情况下,我们的重点在于迅速处理大量图像,而不需要过多关注图像的微小差异。在那些对细致和真实主题色有更高要求的场景中,八叉树无疑是更好的选择。比如:渐变背景色:八叉树能够创造出更加复杂多样的背景效果,从而使渐变过程更加平滑自然,为视觉体验增添层次感。细致的颜色匹配:八叉树具备实现对图像中不同区域进行精细颜色匹配的能力,从而更好地将主题色与整体设计融合。下面就来介绍一下怎么用 JS 实现这两种算法。
4.1 中位切分法
中位切分其实非常好理解。首先需要各位读者发挥下空间想象能力,将R、G、B想象成三维空间的X、Y、Z轴,这样我们就得到了一个边长为256的立方体盒子,接着把每个像素点当作一个无体积的小球根据其坐标(r,g,b)放入盒子中。 中位切分法的核心思想就是不断对某一根轴上的中位数进行切割,与日常生活中的折纸类似(把一张纸对折42次就可以上月球了,由此可见该算法的强大)。不同的是,我们每次会选择像素密度最大子空间的最长边上的中位数进行切割,以确保每次切割过后的子空间中的像素数量大致相等。并在完成每次切分后使用插值排序法进行排序,从而找出密度最大子空间(或质量最大子空间)以进行下一次切割。 某图像颜色在三维空间中的分布示意,可以看到部分区域的像素点非常密集
沿R轴像素中位数处切割成左右2个子空间,右边空间包含的像素数量更多 当子空间边长小于设定的阀值时,我们便将其存入结果数组中。算法终止条件为切割次数或结果数组达到要求,此时密度排序靠前的几个子空间中包含的颜色即为我们所寻找的主题色。以下是 JavaScript 的实现:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
function medianCut(pixels) { const colorRange = getColorRange(pixels);// 获取该颜色盒子的RGB范围 const colorBox = new ColorBox(colorRange, pixels.length, pixels); const divisions = 8; // 最大切割次数,在demo中写死 实际项目中使用者可自定义 let [box1, box2] = cutBox(colorBox); // 第一次切割 return queueCut([box1, box2], divisions);}function queueCut(queue, num) { let isSmallBox = false; while (queue.length < num && !isSmallBox) { const denselyBox = queue.shift(); const resultBox = cutBox(denselyBox); // 只返回一个盒子时,说明最长边已经小于阀值 if (resultBox.length === 1) { isSmallBox = true; queue.unshift(resultBox); return; } queue = insertValueInSortedArray(queue, resultBox); // 插值排序 } return queue;}function cutBox(colorBox) { const { colorRange, total, rgbArr } = colorBox; const cutSide = colorBox.getCutSide(colorRange); if (cutSide === -1) return colorBox; // 当切割边为-1即切割范围小于阀值时 停止切割 const colorInCutSide = rgbArr.map((item) => item[cutSide]); // 统计出各个值的数量 let medianColor = getMedianColor(colorInCutSide, total); // 此处做简易处理,实际处理中需考虑中位数值的像素数量是否过大 if (medianColor === colorRange[cutSide][0]) { medianColor++; } // 防止空切 const newRange = getCutRange(colorRange, cutSide, medianColor); // 获取切割后的两个盒子的RGB范围 const dividedPixels = dividePixel(rgbArr, cutSide, medianColor); // 分割像素到两个盒子中并返回... return [boxOne, boxTwo];}
看到这里是否对中位切分算法有了更深的理解?在实际运用中,要注意切割次数和最小子空间的设定会影响到结果的准确性。
3.2 八叉树
我知道有些同学看到这个名字的时候已经开始头疼了。但请先别急,听我慢慢讲解先用一句话简单概况,八叉树(Octree)是一种用于描述三维空间的树状数据结构,它是一种递归的树形数据结构,可以将三维空间划分为八个等分的小立方体(子节点),每个空间可以进一步细分为八个子空间,以此类推。八叉树怎么和RGB颜色盒子连接起来呢? 首先,要将三维空间划分成八个等分的小立方体,我们需要沿着每条轴的中点切割,确保每个子空间的边长相等,从而确保每个立方体的体积相等。比如将一座葫芦山(完整颜色盒子)用八叉树切分后会得到八种不同颜色的葫芦娃; (没办法葫芦娃只有七个人,拿蛤蟆凑个数)但是其颜色,比如蓝色的范围太广泛了,其分为天蓝色、宝蓝色、钢蓝色、葫蓝色、蔚蓝色、紫蓝色等等,天蓝色又分为淡蓝色、浅蓝色、鲜蓝色、翠蓝色、深蓝色等等。那么我们就需要对这些葫芦娃进行递归切割以获取这些更精确的颜色。那么我们要切割到第几层才能获取到具体的像素点(111的颜色盒子)呢?我们可以观察到,每对颜色盒子进行一次切割,会导致子节点继承的RGB范围减半,而256=2^8,这意味着在对一个完整的颜色盒子划分到第八层时,我们就可以获得单个像素点啦。我们知道了八叉树对于颜色盒子的划分规则后,我们该怎么使用八叉树来提取图像主题色呢? 上文中提到八叉树是按照三条轴的中点对空间进行递归切割的,也就是说在八叉树第一层结构中,各轴会被分成[0, 127], [128, 255]两个区间,颜色盒子被均分成八个小盒子。那我们该如何将这些小盒子其与八叉树中的第一层节点对应起来呢?与二分查找法类似,我们需要将像素的RGB值分别与其轴中点进行比较并将结果用0、1来表示(0-小于中点,1-大于中点),在第一层中得到的对应关系如下图所示。 以此类推,我们最终得到以下对应关系。 以此类推,我们可以得到各像素点在八叉树中所挂载的节点(如下图所示)。八叉树将颜色数据按照层级关系组织与管理起来,从而实现高效的存储和查找操作。
知道了怎么将像素数据存入八叉树结构后,我们就可以开始提取主题色啦。 首先,在八叉树最后一层结构中,每个节点表示的是一个具体的RGB值,我们称其为叶子节点。与此同时,在最后一层中每个兄弟节点的RGB差值范围为1,也就是说该父节点的所有子节点的颜色都十分类似。我们可以发现,在八叉树较深层级中,兄弟节点的RGB差值范围较小,公式为diff=2^(8-n),其中diff为RGB差值,n为层数。那么我们可以利用这个特性来合并相似元素从而更有效地表示数据,当一个颜色盒子中存有过多小盒子(子节点)与葫芦娃(像素)的时候,丢弃其中所有小盒子,并保留所有葫芦娃至父盒子中,从而进行空间压缩,减少空间复杂度。为什么要进行合并呢?在介绍中位切分法的篇幅中,我们提到,提取主题色其实就是找到密度最集中的几个簇,被合并的盒子的像素都为那些像素集中且密度较大的盒子。这便是我们所需找到的簇,也就是主题色。如果将所有像素全都储存在八叉树结构后,才进行合并叶子节点的步骤的话,这无疑极大的增加了空间复杂度,所以我们设定了一个阀值N(可自定义),一般来说,N越大,得到的结果越精确,但时间复杂度与空间复杂也会同步上升。在将像素插入八叉树结构的过程中,当叶子节点数量超过N时,我们便会将挂有最多叶子节点的父节点进行合并操作。合并后,将该父节点设为叶子节点,且之后所有在该分支上的像素均挂载至该父节点上。当所有的像素数据遍历结束后,我们就可以得到N个叶子节点了。我们需要根据每个叶子节点上的像素数量对其进行排序,排名前列的便是我们所需要的主题色啦。以上介绍了中值切分法和八叉树两种算法的实现中位切分法适用于资源有限且需要快速提取图像主题色的场景,尤其适合实时或大规模处理需求。它的简单性和计算效率使其在这些场景下成为很好的选择,尤其是当主要目标是从图像中快速捕捉主题颜色而不需要过多考虑细节和渐变色时。比如当商户只需要快速批量地获取图片的主题色,且不关心主题色的精度时而八叉树在动态适应性和处理颜色渐变方面具有优势,适用于需要保持细节不和平滑度的图像颜色提取任务,但其算法复杂度较高,内存消耗较大。主要应用场景如下:并且该两种算法我们都会放在 Web Worker 中处理,以提高页面性能和用户体验,同时保持主线程的渲染进程不受影响。
五、成果
以上就是这篇文章的总体内容了,通过 Canvas,可以在 Web 前端对图像进行操作和渲染。结合切割算法,可以实现类似的图像颜色提取功能。这种方法尤其适合需要在用户界面中实时显示颜色提取结果的交互式应用。并且通过对于 Web Worker的一个使用,避免了对主线程渲染的影响,从而提升了用户体验。
接下来我们看一下会员卡接入该功能之后的一个整体效果吧。会员权益卡接入该功能后,当用户上传完卡面图片,便会自动提取其中的主题色。这样,用户可以直接选择提取出的色彩作为主题色应用,以确保卡面图片和主题色的协调性。具体效果如下所示: 权益卡效果展示会员等级卡接入该功能后的效果图如下所示:等级卡效果展示我们还可以利用该工具,当加载一些比较耗时的大图片时使用提取出的主题色进行渐变填充,实现一种"模糊渐变加载"的过渡效果。这种过渡场景可以增加页面加载的视觉吸引力和平滑性。如下图所示:
六、参考资料
1、中位切分法 - 简书(https://www.jianshu.com/p/a2cc58eba182)2、三维数据存储-OcTree八叉树(https://sunjiadai.xyz/blog/2019/06/25/OcTree%E5%85%AB%E5%8F%8...)3、Wei-dong, C., & Wei, D. (2008, December). An improved median-cut algorithm of color image quantization. In 2008 International Conference on Computer Science and Software Engineering (Vol. 2, pp. 943-946). IEEE.4、Yamaguchi, K., Kunii, T., Fujimura, K., & Toriya, H. (1984). Octree-related data structures and algorithms. IEEE Computer Graphics and Applications, 4(01), 53-59.
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。