要件
以下のようなシナリオを想定してください
あるウェブサイトのバナー領域において、1910×560pxのサイズで背景画像が表示されています。この画像の本来のサイズは1440px幅であり、background-sizeがcontainに設定されているため、両端の青色部分は背景色で埋められています。
問題点 これはスライドショー形式のバナーであり、別の色の画像を追加したい場合、各画像に対して事前に背景色を指定する必要があるのでしょうか?これは非常に非効率的な方法です。
この課題を解決するには、画像から主な色を抽出する必要があります。これはJavaScriptでも簡単に実現可能です。既存のオープンソースライブラリが多数存在します。
探索
ネット上で見つけた以下のライブラリがあります:
- color-thief:JavaScriptとCanvasを用いた画像から主要色や代表的なカラーパレットを抽出するツール
- vibrant.js:AndroidサポートライブラリのPaletteクラスのJavaScript版で、画像から目立つ色を抽出
- rgbaster.js:画像の主色・副色情報を取得し、Webインタラクションを実現する小さなスクリプト
最も軽量なrgbaster.js(このライブラリはTSで書かれているにもかかわらず、npmパッケージにtypesが指定されていないという面白い特徴があります)をテストした結果、グラデーション画像では7万以上の色値を返すことが判明しました。正確に最大面積の色を抽出していますが、その色は画像の端の色ではなく、背景色として使用すると完全に融合することができません。
他のライブラリについては以下の記事を参考にしてください:
- 記事1:blog.csdn.net/weixin_4299…
- 記事2:juejin.cn/post/684490…
- 記事3:www.zhangxinxu.com/wordpress/2…
これらのライブラリは主に色抽出機能に特化しており、実際の応用シーンでは不十分です。特に複雑なカラーパレットやグラデーション画像には適用が難しいことが判明しました。
思考
既存のライブラリが不十分であるため、独自に実装することにしました。
要件を整理すると、以下の3点が求められます:
- 画像の主色(面積比最大)
- 画像の副色(面積比第二位)
- 適切な背景色(画像の端の色、グラデーションでは端の色を背景色として使用)
これにより、ほとんどのニーズをカバーできます。1+2は関連するテーマタグや背景色生成に、3は余白部分の容器と完璧に融合させることに利用可能です。
実装
⚠ このセクションは高度な内容です。原理の深掘りは飛ばしていただいても構いません。最後に使用法とデモ画像が掲載されています ⚠
アプローチ
既存のライブラリの欠点を回避するために、グラデーション画像の処理に注意が必要です。数万の色を抽出することはユーザー体験に悪影響を与えるため、グラデーションパス上の各点の色は異なるため、しきい値で分類し、近い色を選び平均色を計算する必要があります。これにより、主色が正確すぎることによる代表性の低下を防ぎます。
背景色については、以下の2つのケースがあります。
- ページ全体の調和を図る場合は、主色を使用してグラデーションやオーバーレイを適用
- 画像と背景の境界が見えないような融合を図る場合は、画像の端の色を抽出
最後に、画像の解像度が高い場合、ピクセルポイントを巡回する際にパフォーマンスが低下するため、サンプリング率を調整する必要があります。精度の低下はあるものの、適切な値に設定すれば基本的には使用可能です。
詳細については以下のコードで説明します。
JavaScriptによる実装
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 {
/**
* - 降采样後の最大サイズ(デフォルト100px)
* - 降采样後の画像サイズはこの値を超えない
* - 降采样後のサイズが小さいほど処理速度は速く、色抽出の正確性は低下する可能性がある
**/
maxSize?: number
/**
* - Lab距離しきい値(デフォルト10)
* - この値以下の色は同一クラスタとみなされる
* - 値が大きいほど色がマージされやすく、抽出される色は少なくなる
* - 値が小さいほど色が区別されやすく、抽出される色は多くなる
**/
threshold?: number | thresholdObj
}
概念解説:Lab(CIE Lab*)はCIE XYZカラーモデルの改良版です。「L」(明度)、「a」(緑から赤)、「b」(青から黄)の3つの値で表されます。XYZと比較して、CIE Lab*は人間の視覚に近い色彩表現を可能にします。
次に、画像をロードする関数を実装します:
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)
}
})
}
これで画像オブジェクトを取得できます。
画像が大きすぎる場合、降采样処理が必要です:
// Canvasを用いて画像を降采样し、ImageDataオブジェクトを返す
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)は、画像処理においてデータのサンプリング率や解像度を減少させることでデータ量を削減するプロセスです。ここでは単純に画像を100x100以内に強制的に圧縮する方法として扱っています。
画像情報を取得後、ピクセルを巡回して処理します。思考セクションで述べたように、近い色を抽出し平均色を求め、主色・副色を取得します。
問題は「どの色が近い色か?」です。通常のRGBでは計算できません。なぜなら、感知均一性の問題があるからです。
感知均一性とは、XYZシステムとその色度図上での2つの色の距離が、観察者の視覚的な変化と一致しないという問題です。この問題を解決するために、sRGBをLabカラースペースに変換し、欧幾里得距離を計算します。
したがって、まずRGBをLabカラースペースに変換する必要があります:
// sRGBからLabカラースペースへの変換
function sRGBtoLab(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に変換(ガンマ補正の除去)0.04045はsRGBカラースペースのしきい値
- 線形RGBをXYZ空間に変換
- XYZ値を正規化(D65標準白点を参照)
- XYZからLabへの変換
次にクラスタリングアルゴリズムを実装します:
/**
* 条件を満たすピクセルをクラスタリング
* @param imageData 画像のピクセルデータ
* @param condition ピクセルが指定領域に属するかを判断する関数(x, y を引数に)
* @param threshold Lab距離しきい値、この値以下の色は同一クラスタとみなされる
*/
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 = sRGBtoLab(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が呼び出されています:
// 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)
}
概念解説:欧幾里得距離は、n次元空間で2点間の直線距離を測定する方法です。これは各次元の差の平方和の平方根で計算されます。
この関数はK-meansクラスタリングと同様のアプローチを採用し、しきい値以下のLab距離の色をクラスタにまとめ、平均色(Lab値)を取得します。
概念解説:クラスタリングアルゴリズムは、データセット内の要素を異なるグループ(クラスタ)に分ける無教師学習手法です。ここでは近い色をクラスタにまとめています。
概念解説:色クラスタは、クラスタリングアルゴリズムにおける「一群」と理解できます。
色クラスタ集合を取得後、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
これで上下左右のエッジカラーを取得できました🎉🎉🎉
これで作業はほぼ完了です。最終的に必要な属性をユーザーに提供します。主関数は以下のようになります:
/**
* 主関数:画像から自動的に色を抽出
* @param imageSource 画像URLまたはHTMLImageElement
* @returns 主色、副色、背景色(上下左右)を含む結果オブジェクト
*/
export default async function colorPicker(imageSource: HTMLImageElement | string, options?: autoColorPickerOptions): Promise<AutoHueResult> {
const { maxSize, threshold } = __handleAutoHueOptions(options)
const img = await loadImage(imageSource)
// 降采样(最大サイズ100px、必要に応じて調整可能)
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(クラスタサイズを設定するしきい値)をカスタマイズできます。
ユーザー体験向上のため、thresholdパラメータのオプション型を定義しました:
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で色塊要素を表示
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の色値を使用、境界がほとんど見えません)
参考文献
- zhuanlan.zhihu.com/p/370371059
- baike.baidu.com/item/%E5%9B%BE%E5%83%8F%E5%99%A8
- baike.baidu.com/item/%E7%8E%8B%E8%8A%AF%E5%8F%AF%E8%BF%9B%E6%80%A7
- zh.wikipedia.org/wiki/%E6%AC%A2%E7%8E%8B%E5%8F%AF%E8%BF%9B%E6%80%A7
- blog.csdn.net/weixin_4256…
- zh.wikipedia.org/wiki/K-%E5%88%86%E7%8E%8B%E6%95%B0
- blog.csdn.net/weixin_4299…
- juejin.cn/post/684490…
外伝
Autoファミリーの他のメンバー
- Auto-Plugin/autofit.js:これまでで最も使いやすい自適応ツール
- Auto-Plugin/autolog.js:軽量なポップアップ
- Auto-Plugin/autouno:直感的なUnoCSSプリセット方案
- Auto-Plugin/autohue.js:画像と背景を自然に融合させる自動抽出ツール