この記事はWano Group Advent Calendar 2024 9日目の記事です。
やったこと
こういうジャケット写真やアーティスト写真っぽいのを
主要色抜き出してアクセントカラーも割り出す実験
音楽ドメインでたまにやる背景処理
Wano Groupでは Tune Core Japan や Video Kicks など、音楽やMV、自作カラオケの配信代行を事業として扱っています。
音楽関連事業では、以下のような正方形のアーティスト写真やジャケット写真を扱います。
我々は、アーティストさんやリリース固有のページやバナー/OGPシェア画像をプロシージャルに作る機会があります。
多少それぞれの個性を出すために、このジャケット素材を使って以下の画像のような背景処理をやることが多いです。
すなわち、
1.ぺージ背景に素材を拡大して載せ
2.ガウシアンブラーをかける
3.輝度下げる
みたいなやつですね。
お手軽にジャケ写特有の個性を全体反映することができて、とても便利な手法です。必要なものが画像1枚なのがまた良いところです。低コストで実装も易しい。
ただ、若干飽きてきたのもあります。
今回は手持ちのネタを増やすために画像の色解析に挑戦してみました。
実装方針
ジャケ写の「主要色群」を使うとなにかおもしろいことができないかな?ということで差し色を解析することを目的としました。
せっかくなら、OGP画像としてリアルタイム生成できると嬉しいです。
チームでは、OGP画像生成や画像の圧縮を多少強めのAWS Lambdaの上でGoやヘッドレスブラウザを用いてレンダリングすることが多いです。
この事情から、今回の画像解析はGo言語でやることとします。
Goはともかく、色とかそのパレットとかの理論は一切詳しくないので、Claude先生(AI)との壁打ちによりそのへんは書いてもらうこととします。
解析本体
全体のコントロール
- 元の画像のダウンサンプル(簡易的に64x64や128x128の画像に圧縮)
- 主要色群を割り出し、
- 色相上その反対となる色をざっくりアクセントとして採用する
のが全体の流れです。
package accent_color
import (
"bytes"
"errors"
"image"
_ "image/jpeg"
_ "image/png"
"sort"
)
func Process(data []uint8) (ou Output, err error) {
// 画像をデコード
img, format, err := image.Decode(bytes.NewReader(data))
if err != nil {
return ou, err
}
img = DownsampleImage(img, 72)
bounds := img.Bounds()
width := bounds.Max.X - bounds.Min.X
height := bounds.Max.Y - bounds.Min.Y
// 色の出現頻度を追跡するマップ
colorFrequencies := make(map[RGB]int)
totalPixels := 0
// 画像をスキャンして色情報を収集
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
r, g, b, a := img.At(x, y).RGBA()
// 完全な透明は無視
if uint8(a>>8) < 128 {
continue
}
rgb := RGB{
R: uint8(r >> 8),
G: uint8(g >> 8),
B: uint8(b >> 8),
}
colorFrequencies[rgb]++
totalPixels++
}
}
// 有効なピクセルが見つからない場合
if totalPixels == 0 {
return ou, errors.New("画像に有効なピクセルが見つかりません")
}
// 色の頻度でソート
var sortedColors []ColorWithFrequency
for color, freq := range colorFrequencies {
sortedColors = append(sortedColors, ColorWithFrequency{
Color: color,
Frequency: freq,
})
}
sort.Slice(sortedColors, func(i, j int) bool {
return sortedColors[i].Frequency > sortedColors[j].Frequency
})
// ソートされた色のリストをクラスタリング (近すぎる色はまとめる)
colorThreshold := 25.0 // Lab色空間での距離閾値
sortedColors = clusterColors(sortedColors, colorThreshold)
// 代表色の抽出(上位3色)
dominantColorsRGB := make([]RGB, 0, 3)
var totalHSL HSL
var colorCount int
maxColors := min(3, len(sortedColors))
for i := 0; i < maxColors; i++ {
rgb := sortedColors[i].Color
hsl := rgbToHSL(rgb)
totalHSL.H += hsl.H
totalHSL.S += hsl.S
totalHSL.L += hsl.L
colorCount++
dominantColorsRGB = append(dominantColorsRGB, rgb)
}
// 代表色の平均HSLを計算
avgHSL := HSL{
H: normalizeHue(totalHSL.H / float64(colorCount)),
S: totalHSL.S / float64(colorCount),
L: totalHSL.L / float64(colorCount),
}
// アクセントカラーの生成
accentHSL := generateAccentColor(avgHSL)
accentRGB := hslToRGB(accentHSL)
lightAccentColor := generateLightAccentColor(accentHSL)
lightAccentRGB := hslToRGB(lightAccentColor)
vividAccentColor := generateVividAccentColor(accentHSL)
vividAccentRGB := hslToRGB(vividAccentColor)
return Output{
MainColors: dominantColorsRGB,
Width: width,
Height: height,
AccentColor: accentRGB,
LightAccentColor: lightAccentRGB,
VividAccentColor: vividAccentRGB,
Format: format,
}, nil
}
type Output struct {
Width int
Height int
MainColors []RGB
AccentColor RGB
LightAccentColor RGB
VividAccentColor RGB
Format string
Error string
}
WASMからの入力でテストしたのでシグネチャがdata []uint8とかになってますが、
OGP画像を発行するLambdaで動かすときはS3から取得するつもりなので、多少ここは将来的に変えるかも知れません。
まずダウンサンプルをするのは、当然処理速度のためです。
このやり方であれば、元画像が大きくても速度には影響が少ないかな、と。
主要色候補は、色と色の距離があまりにも近すぎるものはグループ化したうえで頻出度をカウントしています。
画像処理ヘルパー関数群
色周りのヘルパーは特にAIにお世話になりました。というかわからん。
色相変換やHSLからRGBへの変換などは一般ソフトでよく触っていても、自分で書ける気あんましないですね。
自前でもimagingパッケージとかのOSS使う気がします。
package accent_color
import (
"image"
_ "image/jpeg"
_ "image/png"
"math"
"sort"
)
// DownsampleImage
// 単純なダウンサンプリング
func DownsampleImage(img image.Image, targetSize int) image.Image {
bounds := img.Bounds()
origW := bounds.Max.X - bounds.Min.X
origH := bounds.Max.Y - bounds.Min.Y
// スケール係数を計算
scaleX := float64(origW) / float64(targetSize)
scaleY := float64(origH) / float64(targetSize)
newImg := image.NewRGBA(image.Rect(0, 0, targetSize, targetSize))
for y := 0; y < targetSize; y++ {
for x := 0; x < targetSize; x++ {
// 元画像の対応するピクセルを取得
srcX := int(float64(x) * scaleX)
srcY := int(float64(y) * scaleY)
c := img.At(srcX+bounds.Min.X, srcY+bounds.Min.Y)
newImg.Set(x, y, c)
}
}
return newImg
}
// 肌色判定
func isKinTone(hsl HSL) bool {
// H: 0-35の範囲に制限(黄色みを抑える)
// S: 0.15-0.5の範囲に調整(より自然な彩度に)
// L: 0.55-0.85の範囲に調整(より自然な明度に)
return (hsl.H >= 0 && hsl.H <= 35) &&
(hsl.S >= 0.15 && hsl.S <= 0.5) &&
(hsl.L >= 0.55 && hsl.L <= 0.85)
}
// アクセントカラー生成
func generateAccentColor(baseHSL HSL) HSL {
hue := normalizeHue(baseHSL.H + 180)
saturation := math.Min(baseHSL.S*1.2, 1.0)
lightness := baseHSL.L
if baseHSL.L < 0.5 {
lightness = math.Min(baseHSL.L+0.3, 0.9)
} else {
lightness = math.Max(baseHSL.L-0.3, 0.1)
}
return HSL{H: hue, S: saturation, L: lightness}
}
// RGB -> HSL 変換
func rgbToHSL(rgb RGB) HSL {
r := float64(rgb.R) / 255
g := float64(rgb.G) / 255
b := float64(rgb.B) / 255
max := math.Max(math.Max(r, g), b)
min := math.Min(math.Min(r, g), b)
h, s := 0.0, 0.0
l := (max + min) / 2
if max != min {
d := max - min
s = d / (2 - max - min)
if l > 0.5 {
s = d / (2 - (max + min))
}
switch max {
case r:
h = (g - b) / d
if g < b {
h += 6
}
case g:
h = 2 + (b-r)/d
case b:
h = 4 + (r-g)/d
}
h *= 60
}
return HSL{H: h, S: s, L: l}
}
// HSL -> RGB 変換
func hslToRGB(hsl HSL) RGB {
if hsl.S == 0 {
v := uint8(hsl.L * 255)
return RGB{v, v, v}
}
var q float64
if hsl.L < 0.5 {
q = hsl.L * (1 + hsl.S)
} else {
q = hsl.L + hsl.S - hsl.L*hsl.S
}
p := 2*hsl.L - q
r := hueToRGB(p, q, hsl.H+120)
g := hueToRGB(p, q, hsl.H)
b := hueToRGB(p, q, hsl.H-120)
return RGB{
R: uint8(r * 255),
G: uint8(g * 255),
B: uint8(b * 255),
}
}
// 色相からRGB値への変換を補助する関数
func hueToRGB(p, q, h float64) float64 {
h = normalizeHue(h)
h = h / 360
if h < 1.0/6.0 {
return p + (q-p)*6*h
}
if h < 0.5 {
return q
}
if h < 2.0/3.0 {
return p + (q-p)*(2.0/3.0-h)*6
}
return p
}
// 色相を0-360の範囲に正規化
func normalizeHue(h float64) float64 {
h = math.Mod(h, 360)
if h < 0 {
h += 360
}
return h
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// float64用のmin関数
func minFloat64(a, b float64) float64 {
if a < b {
return a
}
return b
}
// 色の距離を計算する関数(CIE76色差を簡易的に実装)
func colorDistance(rgb1, rgb2 RGB) float64 {
lab1 := rgbToLab(rgb1)
lab2 := rgbToLab(rgb2)
return math.Sqrt(
math.Pow(lab1.L-lab2.L, 2) +
math.Pow(lab1.A-lab2.A, 2) +
math.Pow(lab1.B-lab2.B, 2),
)
}
// RGB から Lab への変換に必要な構造体と関数
type Lab struct {
L, A, B float64
}
func rgbToLab(rgb RGB) Lab {
// RGB -> XYZ
r := float64(rgb.R) / 255.0
g := float64(rgb.G) / 255.0
b := float64(rgb.B) / 255.0
// ガンマ補正
r = gammaCorrection(r)
g = gammaCorrection(g)
b = gammaCorrection(b)
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 -> Lab
x = labHelper(x / 0.95047)
y = labHelper(y / 1.00000)
z = labHelper(z / 1.08883)
return Lab{
L: math.Max(0, 116*y-16),
A: 500 * (x - y),
B: 200 * (y - z),
}
}
func gammaCorrection(v float64) float64 {
if v <= 0.04045 {
return v / 12.92
}
return math.Pow((v+0.055)/1.055, 2.4)
}
func labHelper(v float64) float64 {
if v > 0.008856 {
return math.Pow(v, 1.0/3.0)
}
return (903.3*v + 16) / 116
}
// 色をクラスタリングする関数
func clusterColors(colors []ColorWithFrequency, threshold float64) []ColorWithFrequency {
if len(colors) == 0 {
return colors
}
clusters := make([][]ColorWithFrequency, 0)
used := make(map[int]bool)
// 各色について
for i := 0; i < len(colors); i++ {
if used[i] {
continue
}
// 新しいクラスタを作成
cluster := []ColorWithFrequency{colors[i]}
used[i] = true
// 近い色を探してクラスタに追加
for j := i + 1; j < len(colors); j++ {
if used[j] {
continue
}
if colorDistance(colors[i].Color, colors[j].Color) < threshold {
cluster = append(cluster, colors[j])
used[j] = true
}
}
clusters = append(clusters, cluster)
}
// 各クラスタの代表色を選択
result := make([]ColorWithFrequency, 0)
for _, cluster := range clusters {
// クラスタ内の頻度を合計
totalFreq := 0
var weightedR, weightedG, weightedB float64
for _, c := range cluster {
totalFreq += c.Frequency
weightedR += float64(c.Color.R) * float64(c.Frequency)
weightedG += float64(c.Color.G) * float64(c.Frequency)
weightedB += float64(c.Color.B) * float64(c.Frequency)
}
// 頻度で重み付けした平均色を計算
avgColor := RGB{
R: uint8(weightedR / float64(totalFreq)),
G: uint8(weightedG / float64(totalFreq)),
B: uint8(weightedB / float64(totalFreq)),
}
result = append(result, ColorWithFrequency{
Color: avgColor,
Frequency: totalFreq,
})
}
// 頻度でソート
sort.Slice(result, func(i, j int) bool {
return result[i].Frequency > result[j].Frequency
})
return result
}
// HSL色空間の値を表現する構造体
type HSL struct {
H float64 // 色相: 0-360度
S float64 // 彩度: 0-1
L float64 // 明度: 0-1
}
// RGB色空間の値を表現する構造体
type RGB struct {
R, G, B uint8
}
// 色とその出現頻度を保持する構造体
type ColorWithFrequency struct {
Color RGB
Frequency int
}
// generateLightAccentColor は明るいアクセントカラーを生成します
func generateLightAccentColor(baseHSL HSL) HSL {
// 彩度を少し下げ、明度を上げて白に近づける
lightAccent := HSL{
H: baseHSL.H, // 色相は維持
S: baseHSL.S * 0.7, // 彩度を70%に抑える
L: minFloat64(0.95, baseHSL.L*1.3+0.2), // 明度を上げるが95%を超えないように
}
return lightAccent
}
// generateVividAccentColor は彩度の高いアクセントカラーを生成します
func generateVividAccentColor(baseHSL HSL) HSL {
// 彩度を上げ、明度を適度に調整して鮮やかな色を作る
vividAccent := HSL{
H: baseHSL.H,
S: minFloat64(baseHSL.S*1.4+0.2, 0.95), // 彩度を140%に増加させ、0.2を加算(上限95%)
L: minFloat64(baseHSL.L*0.9+0.1, 0.75), // 明度を少し抑えて色を濃くする(上限75%)
}
return vividAccent
}
結果
開発段階ではWASMモジュールとして開発しました。
ある程度まで作って力尽きたのが以下です。
う~~ん。
結局は「OGPやバナーデザインによる」みたいな答えが妥当かもしれませんね...。
なにかには使えそうかとは思いますが。パレットのセンスや色配置の理論とか、必要なのはそちらの知識かもしれません。
ただ、「こういうことも画像一枚でできるかもよ」みたいなのはデザイナーさんと相談できるようにはなるかと思いますので、どこかで発展させたものを使うかも知れません。
簡単ですが本稿を終わります。