需求
先來看這樣一個場景,拿一個網站舉例

這里有一個常見的網站 banner 圖容器,大小為為1910*560,看起來背景圖完美的充滿了寬度,但是圖片原始大小時,卻是:

它的寬度只有 1440,且 background-size 設置的是 contain ,即等比例縮放,那么可以斷定它兩邊的藍色是依靠背景色填充的。
那么問題來了,這是一個 輪播banner,如果希望添加一張不是藍色的圖片呢?難道要給每張圖片提前標注好背景顏色嗎?這顯然是非常死板的做法。
所以需要從圖片中提取到圖片的主題色,當然這對于 js 來說,也不是什么難事,市面上已經有眾多的開源庫供我們使用。
探索
首先在網絡上找到了以下幾個庫:
- color-thief 這是一款基于 JavaScript 和 Canvas 的工具,能夠從圖像中提取主要顏色或代表性的調色板
- vibrant.js該插件是 Android 支持庫中 Palette 類的 JavaScript 版本,可以從圖像中提取突出的顏色
- rgbaster.js 這是一段小型腳本,可以獲取圖片的主色、次色等信息,方便實現一些精彩的 Web 交互效果
我取最輕量化的 rgbaster.js(此庫非常搞笑,用TS編寫,npm 包卻沒有指定 types) 來測試后發現,它給我在一個漸變色圖片中,返回了七萬多個色值,當然,它準確的提取出了面積最大的色值,但是這個色值不是圖片邊緣的顏色,導致設置為背景色后,并不能完美的融合。
另外的插件各位可以參考這幾篇文章:
可以發現,這些插件主要功能就是取色,并沒有考慮實際的應用場景,對于一個圖片顏色分析工具來說,他們做的很到位,但是在大多數場景中,他們往往是不適用的。
在文章 2 中,作者對比了三款插件對于圖片容器背景色的應用,看起來還是 rgbaster 效果好一點,但是我們剛剛也拿他試了,它并不能適用于顏色復雜度高的、漸變色的圖片。
思考
既然又又又沒有人做這件事,正所謂我不入地獄誰入地獄,我手寫一個
整理一下需求,我發現我希望得到的是:
- 圖片的主題色(面積占比最大)
- 次主題色(面積占比第二大)
- 合適的背景色(即圖片邊緣顏色,漸變時,需要邊緣顏色來設置背景色)
這樣一來,就已經可以覆蓋大部分需求了,1+2 可以生成相關的 主題 TAG、主題背景,3 可以使留白的圖片容器完美融合。
開搞
?? 本小節內容非常硬核,如果不想深究原理可以直接跳過,文章末尾有用法和效果圖 ??
思路
首先需要避免上面提到的插件的缺點,即對漸變圖片要做好處理,不能取出成千上萬的顏色,體驗太差且實用性不強,對于漸變色還有一點,即在漸變路徑上,每一點的顏色都是不一樣的,所以需要將他們以一個閾值分類,挑選出一眾相近色,并計算出一個平均色,這樣就不會導致主題色太精準進而沒有代表性。
對于背景色,需要按情況分析,如果只是希望做一個協調的頁面,那么大可以直接使用主題色做漸變過渡或蒙層,也就是類似于這種效果

但是如果希望背景與圖片完美銜接,讓人看不出圖片邊界的感覺,就需要單獨對邊緣顏色取色了。
最后一個問題,如果圖片分辨率過大,在遍歷像素點時會非常消耗性能,所以需要降低采樣率,雖然會導致一些精度上的丟失,但是調整為一個合適的值后應該基本可用。
剩余的細節問題,我會在下面的代碼中解釋
使用 JaveScript 編碼
接下來我將詳細描述 autohue.js 的實現過程,由于本人對色彩科學
不甚了解,如有解釋不到位或錯誤,還請指出。
首先編寫一個入口主函數,我目前考慮到的參數應該有:
export default async function colorPicker(imageSource: HTMLImageElement | string, options?: autoColorPickerOptions)
type thresholdObj = { primary?: number; left?: number; right?: number; top?: number; bottom?: number }
interface autoColorPickerOptions {
maxSize?: number
threshold?: number | thresholdObj
}
概念解釋 Lab ,全稱:CIE L*a*b
,CIE L*a*b*
是CIE XYZ
色彩模式的改進型。它的“L”(明亮度),“a”(綠色到紅色)和“b”(藍色到黃色)代表許多的值。與XYZ比較,CIE L*a*b*
的色彩更適合于人眼感覺的色彩,正所謂感知均勻
然后需要實現一個正常的 loadImg 方法,使用 canvas 異步加載圖片
function loadImage(imageSource: HTMLImageElement | string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
let img: HTMLImageElement
if (typeof imageSource === 'string') {
img = new Image()
img.crossOrigin = 'Anonymous'
img.src = imageSource
} else {
img = imageSource
}
if (img.complete) {
resolve(img)
} else {
img.onload = () => resolve(img)
img.onerror = (err) => reject(err)
}
})
}
這樣我們就獲取到了圖片對象。
然后為了圖片過大,我們需要進行降采樣處理
function getImageDataFromImage(img: HTMLImageElement, maxSize: number = 100): ImageData {
const canvas = document.createElement('canvas')
let width = img.naturalWidth
let height = img.naturalHeight
if (width > maxSize || height > maxSize) {
const scale = Math.min(maxSize / width, maxSize / height)
width = Math.floor(width * scale)
height = Math.floor(height * scale)
}
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('無法獲取 Canvas 上下文')
}
ctx.drawImage(img, 0, 0, width, height)
return ctx.getImageData(0, 0, width, height)
}
概念解釋,降采樣:降采樣(Downsampling)是指在圖像處理中,通過減少數據的采樣率或分辨率來降低數據量的過程。具體來說,就是在保持原始信息大致特征的情況下,減少數據的復雜度和存儲需求。這里簡單理解為將圖片強制壓縮為 100*100 以內,也是 canvas 壓縮圖片的常見做法。
得到圖像信息后,就可以對圖片進行像素遍歷處理了,正如思考中提到的,我們需要對相近色提取并取平均色,并最終獲取到主題色、次主題色。
那么問題來了,什么才算相近色,對于這個問題,在 常規的 rgb 中直接計算是不行的,因為它涉及到一個感知均勻的問題
概念解釋,感知均勻:XYZ系統和在它的色度圖上表示的兩種顏色之間的距離與顏色觀察者感知的變化不一致,這個問題叫做感知均勻性(perceptual uniformity)問題,也就是顏色之間數字上的差別與視覺感知不一致。由于我們需要在顏色簇中計算出平均色,那么對于人眼來說哪些顏色是相近的?此時,我們需要把 sRGB 轉化為 Lab 色彩空間(感知均勻的),再計算其歐氏距離,在某一閾值內的顏色,即可認為是相近色。
所以我們首先需要將 rgb 轉化為 Lab 色彩空間
function rgbToLab(r: number, g: number, b: number): [number, number, number] {
let R = r / 255,
G = g / 255,
B = b / 255
R = R > 0.04045 ? Math.pow((R + 0.055) / 1.055, 2.4) : R / 12.92
G = G > 0.04045 ? Math.pow((G + 0.055) / 1.055, 2.4) : G / 12.92
B = B > 0.04045 ? Math.pow((B + 0.055) / 1.055, 2.4) : B / 12.92
let X = R * 0.4124 + G * 0.3576 + B * 0.1805
let Y = R * 0.2126 + G * 0.7152 + B * 0.0722
let Z = R * 0.0193 + G * 0.1192 + B * 0.9505
X = X / 0.95047
Y = Y / 1.0
Z = Z / 1.08883
const f = (t: number) => (t > 0.008856 ? Math.pow(t, 1 / 3) : 7.787 * t + 16 / 116)
const fx = f(X)
const fy = f(Y)
const fz = f(Z)
const L = 116 * fy - 16
const a = 500 * (fx - fy)
const bVal = 200 * (fy - fz)
return [L, a, bVal]
}
這個函數使用了看起來很復雜的算法,不必深究,這是它的大概解釋:
獲取到 rgb 參數
轉化為線性 rgb(移除 gamma矯正),常量 0.04045 是sRGB(標準TGB)顏色空間中的一個閾值,用于區分非線性和線性的sRGB值,具體來說,當sRGB顏色分量大于0.04045時,需要通過 gamma 校正(即采用 ((R + 0.055) / 1.055) ^ 2.4
)來得到線性RGB;如果小于等于0.04045,則直接進行線性轉換(即 R / 12.92
)
線性RGB到XYZ空間的轉換,轉換公式如下:
X = R * 0.4124 + G * 0.3576 + B * 0.1805
Y = R * 0.2126 + G * 0.7152 + B * 0.0722
Z = R * 0.0193 + G * 0.1192 + B * 0.9505
歸一化XYZ值,為了參考白點(D65),標準白點的XYZ值是 (0.95047, 1.0, 1.08883)
。所以需要通過除以這些常數來進行歸一化
XYZ到Lab的轉換,公式函數:const f = (t: number) => (t > 0.008856 ? Math.pow(t, 1 / 3) : 7.787 * t + 16 / 116)
計算L, a, b 分量
L:亮度分量(表示顏色的明暗程度)
a:綠色到紅色的色差分量
b:藍色到黃色的色差分量
接下來實現聚類算法
function clusterPixelsByCondition(imageData: ImageData, condition: (x: number, y: number) => boolean, threshold: number = 10): Cluster[] {
const clusters: Cluster[] = []
const data = imageData.data
const width = imageData.width
const height = imageData.height
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (!condition(x, y)) continue
const index = (y * width + x) * 4
if (data[index + 3] === 0) continue
const r = data[index]
const g = data[index + 1]
const b = data[index + 2]
const lab = rgbToLab(r, g, b)
let added = false
for (const cluster of clusters) {
const d = labDistance(lab, cluster.averageLab)
if (d < threshold) {
cluster.count++
cluster.sumRgb[0] += r
cluster.sumRgb[1] += g
cluster.sumRgb[2] += b
cluster.sumLab[0] += lab[0]
cluster.sumLab[1] += lab[1]
cluster.sumLab[2] += lab[2]
cluster.averageRgb = [cluster.sumRgb[0] / cluster.count, cluster.sumRgb[1] / cluster.count, cluster.sumRgb[2] / cluster.count]
cluster.averageLab = [cluster.sumLab[0] / cluster.count, cluster.sumLab[1] / cluster.count, cluster.sumLab[2] / cluster.count]
added = true
break
}
}
if (!added) {
clusters.push({
count: 1,
sumRgb: [r, g, b],
sumLab: [lab[0], lab[1], lab[2]],
averageRgb: [r, g, b],
averageLab: [lab[0], lab[1], lab[2]]
})
}
}
}
return clusters
}
函數內部有一個 labDistance 的調用,labDistance 是計算 Lab 顏色空間中的歐氏距離的
function labDistance(lab1: [number, number, number], lab2: [number, number, number]): number {
const dL = lab1[0] - lab2[0]
const da = lab1[1] - lab2[1]
const db = lab1[2] - lab2[2]
return Math.sqrt(dL * dL + da * da + db * db)
}
概念解釋,歐氏距離:Euclidean Distance,是一種在多維空間中測量兩個點之間“直線”距離的方法。這種距離的計算基于歐幾里得幾何中兩點之間的距離公式,通過計算兩點在各個維度上的差的平方和,然后取平方根得到。歐氏距離是指n維空間中兩個點之間的真實距離,或者向量的自然長度(即該點到原點的距離)。
總的來說,這個函數采用了類似 K-means 的聚類方式,將小于用戶傳入閾值的顏色歸為一簇,并取平均色(使用 Lab 值)。
概念解釋,聚類算法:Clustering Algorithm 是一種無監督學習方法,其目的是將數據集中的元素分成不同的組(簇),使得同一組內的元素相似度較高,而不同組之間的元素相似度較低。這里是將相近色歸為一簇。
概念解釋,顏色簇:簇是聚類算法中一個常見的概念,可以大致理解為 "一類"
得到了顏色簇集合后,就可以按照count大小來判斷哪個是主題色了
let clusters = clusterPixelsByCondition(imageData, () => true, threshold.primary)
clusters.sort((a, b) => b.count - a.count)
const primaryCluster = clusters[0]
const secondaryCluster = clusters.length > 1 ? clusters[1] : clusters[0]
const primaryColor = rgbToHex(primaryCluster.averageRgb)
const secondaryColor = rgbToHex(secondaryCluster.averageRgb)
現在我們已經獲取到了主題色、次主題色 ??????
接下來,我們繼續計算邊緣顏色
按照同樣的方法,只是把閾值設小一點,我這里直接設置為 1 (threshold.top 等都是1)
const topClusters = clusterPixelsByCondition(imageData, (_x, y) => y < margin, threshold.top)
topClusters.sort((a, b) => b.count - a.count)
const topColor = topClusters.length > 0 ? rgbToHex(topClusters[0].averageRgb) : primaryColor
const bottomClusters = clusterPixelsByCondition(imageData, (_x, y) => y >= height - margin, threshold.bottom)
bottomClusters.sort((a, b) => b.count - a.count)
const bottomColor = bottomClusters.length > 0 ? rgbToHex(bottomClusters[0].averageRgb) : primaryColor
const leftClusters = clusterPixelsByCondition(imageData, (x, _y) => x < margin, threshold.left)
leftClusters.sort((a, b) => b.count - a.count)
const leftColor = leftClusters.length > 0 ? rgbToHex(leftClusters[0].averageRgb) : primaryColor
const rightClusters = clusterPixelsByCondition(imageData, (x, _y) => x >= width - margin, threshold.right)
rightClusters.sort((a, b) => b.count - a.count)
const rightColor = rightClusters.length > 0 ? rgbToHex(rightClusters[0].averageRgb) : primaryColor
這樣我們就獲取到了上下左右四條邊的顏色 ??????
這樣大致的工作就完成了,最后我們將需要的屬性導出給用戶,我們的主函數最終長這樣:
export default async function colorPicker(imageSource: HTMLImageElement | string, options?: autoColorPickerOptions): Promise<AutoHueResult> {
const { maxSize, threshold } = __handleAutoHueOptions(options)
const img = await loadImage(imageSource)
const imageData = getImageDataFromImage(img, maxSize)
let clusters = clusterPixelsByCondition(imageData, () => true, threshold.primary)
clusters.sort((a, b) => b.count - a.count)
const primaryCluster = clusters[0]
const secondaryCluster = clusters.length > 1 ? clusters[1] : clusters[0]
const primaryColor = rgbToHex(primaryCluster.averageRgb)
const secondaryColor = rgbToHex(secondaryCluster.averageRgb)
const margin = 10
const width = imageData.width
const height = imageData.height
const topClusters = clusterPixelsByCondition(imageData, (_x, y) => y < margin, threshold.top)
topClusters.sort((a, b) => b.count - a.count)
const topColor = topClusters.length > 0 ? rgbToHex(topClusters[0].averageRgb) : primaryColor
const bottomClusters = clusterPixelsByCondition(imageData, (_x, y) => y >= height - margin, threshold.bottom)
bottomClusters.sort((a, b) => b.count - a.count)
const bottomColor = bottomClusters.length > 0 ? rgbToHex(bottomClusters[0].averageRgb) : primaryColor
const leftClusters = clusterPixelsByCondition(imageData, (x, _y) => x < margin, threshold.left)
leftClusters.sort((a, b) => b.count - a.count)
const leftColor = leftClusters.length > 0 ? rgbToHex(leftClusters[0].averageRgb) : primaryColor
const rightClusters = clusterPixelsByCondition(imageData, (x, _y) => x >= width - margin, threshold.right)
rightClusters.sort((a, b) => b.count - a.count)
const rightColor = rightClusters.length > 0 ? rgbToHex(rightClusters[0].averageRgb) : primaryColor
return {
primaryColor,
secondaryColor,
backgroundColor: {
top: topColor,
right: rightColor,
bottom: bottomColor,
left: leftColor
}
}
}
還記得本小節一開始提到的參數嗎,你可以自定義 maxSize(壓縮大小,用于降采樣)、threshold(閾值,用于設置簇大?。?/p>
為了用戶友好,我還編寫了 threshold 參數的可選類型:number | thresholdObj
type thresholdObj = { primary?: number; left?: number; right?: number; top?: number; bottom?: number }
可以單獨設置主閾值、上下左右四邊閾值,以適應更個性化的情況。
autohue.js 誕生了
名字的由來:秉承一貫命名習慣,auto 家族成員又多一個,與顏色有關的單詞有好多個,我取了最短最好記的一個 hue(色相),也比較契合插件用途。
此插件已在 github 開源:GitHub autohue.js
npm 主頁:NPM autohue.js
在線體驗:autohue.js 官方首頁
安裝與使用
pnpm i autohue.js
import autohue from 'autohue.js'
autohue(url, {
threshold: {
primary: 10,
left: 1,
bottom: 12
},
maxSize: 50
})
.then((result) => {
console.log(`%c${result.primaryColor}`, 'color: #fff; background: ' + result.primaryColor, 'main')
console.log(`%c${result.secondaryColor}`, 'color: #fff; background: ' + result.secondaryColor, 'sub')
console.log(`%c${result.backgroundColor.left}`, 'color: #fff; background: ' + result.backgroundColor.left, 'bg-left')
console.log(`%c${result.backgroundColor.right}`, 'color: #fff; background: ' + result.backgroundColor.right, 'bg-right')
console.log(`%clinear-gradient to right`, 'color: #fff; background: linear-gradient(to right, ' + result.backgroundColor.left + ', ' + result.backgroundColor.right + ')', 'bg')
bg.value = `linear-gradient(to right, ${result.backgroundColor.left}, ${result.backgroundColor.right})`
})
.catch((err) => console.error(err))
最終效果

復雜邊緣效果

縱向漸變效果(這里使用的是 left 和 right 邊的值,可能使用 top 和 bottom 效果更佳)

純色效果(因為單獨對邊緣采樣,所以無論圖片內容多復雜,純色基本看不出邊界)

突變邊緣效果(此時用css做漸變蒙層應該效果會更好)

橫向漸變效果(使用的是 left 和 right 的色值),基本看不出邊界
轉自https://juejin.cn/post/7471919714292105270
?
該文章在 2025/4/16 14:42:10 編輯過