自分はサイトやアプリを作る際に色を選択するのが非常にセンスがなくて下手っぴなので何とかせねばならないと思い、理屈から勉強するしか無いという思いに至っています。
で、AirbnbのBlogにGuest Experience on iOS7という記事がありまして、友達のウィッシュリストのヘッダ部分の背景色を画像で使われている色から計算するという処理を入れているとのことで、そのライブラリであるBBColorPickerが公開されているので、中身を読んでみました。
このライブラリはBBColorSamplerManagerというシングルトンのクラスが提供されていて、その中では2つのメソッドが提供されていています。1つはcomputePrimaryColorForImageでもう一つはsortColorForImage。
1 2 |
- (void)computePrimaryColorForImage:(UIImage *)image completionBlock:(color_sampler_completion_block_t)completionBlock; - (void)sortColorForImage:(UIImage *)image completionBlock:(color_sampler_sort_completion_block_t)completionBlock; |
名前からしてcomputePrimaryColorForImageは実際にアプリで使ってるヘッダ部分の背景色を計算するメソッド、2個めは使われている色をソートして抜き出すメソッドって感じに見えます。ブログ中での言及されているように非同期で動作するので、引数として完了時のコールバック関数を指定するようになっています。しかしsortColorForImageのコールバック関数の引数はUIColorの配列かとおもいきや、UIImageだったのでちょっと動作がわかりません。
1 2 |
typedef void (^color_sampler_completion_block_t)(UIColor *); typedef void (^color_sampler_sort_completion_block_t)(UIImage *); |
それぞれのコードを読んでみると2つのメソッドは激しくコピペ臭がするのですが、とりあえず必要な処理はcomputePrimaryColorForImageっぽいのでこちらを読むことに。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
CGSize imageSize = image.size; float scale = (image.size.height > image.size.width ? _sampleSize.height / imageSize.height : _sampleSize.width / imageSize.width); if (scale < 1) { imageSize.width = roundf(imageSize.width * scale); imageSize.height = roundf(imageSize.height * scale); } CGRect drawingRect = CGRectMake(0, 0, imageSize.width, imageSize.height); CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); unsigned char *rawData = (unsigned char*) calloc(imageSize.height * imageSize.width * 4, sizeof(unsigned char)); NSUInteger bytesPerPixel = 4; NSUInteger bytesPerRow = bytesPerPixel * imageSize.width; NSUInteger bitsPerComponent = 8; CGContextRef context = CGBitmapContextCreate( rawData, imageSize.width, imageSize.height,bitsPerComponent, bytesPerRow, colorSpace, kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big); CGContextDrawImage(context, drawingRect, image.CGImage); |
ここでまずは画像サイズをちっこくしています。_sampleSizeはinitで32×32に指定されているので、それに合わせて縦横比をキープしながらサイズを小さくして、CGContextに描画してます。
1 |
_sampleSize = CGSizeMake(32, 32); |
これで縦横最大32ピクセルの画像になりました。
そしてこれを今度は1ピクセルずつループで回して分析しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
float red = ((float)rawData[colorIndex] * 1.0f) / 255.0f; float green = ((float)rawData[colorIndex+1] * 1.0f) / 255.0f; float blue = ((float)rawData[colorIndex+2] * 1.0f) / 255.0f; // Convert to Y for brightness. float y = 0.299 * red + 0.587 * green + 0.114 * blue; float u = -0.14713 * red - 0.28886 * green + 0.436 * blue; float v = 0.615 * red - 0.51499 * green - 0.10001 * blue; double min, max, delta, s; min = red < green ? red : green; min = min < blue ? min : blue; max = red > green ? red : green; max = max > blue ? max : blue; delta = max - min; if( max > 0.0 ) s = (delta / max); else s = 0; |
解析にあたってRGBの色をYUV色空間に変換しています。YUVは(Wikipediaによれば)”輝度信号Yと、2つの色差信号を使って表現される色空間”です。結果はy、u、vのそれぞれの変数に入ります。
そしてもう一つ、 RGBの最大最小を取って差分を最大値で割ってますが、これはHSV色空間の彩度(Saturation)を計算しています。結果はsに入ります。
そしてこれらの結果から、その色を採用するかどうかを決めています。
1 2 |
//Check if valid color brightness if (0.3 < y && y < 0.9 && fabs(u) > 0.05 && fabs(v) > 0.05 && s > 0.3) { |
これによると白黒に近い色を除くことで、より画像の特徴を表してそうな色を取り出すようにしているという感じでしょうか。この数値はすごくマジックナンバーな感じですが、どうやって決めたんでしょうね。いろいろ試してチューニングしたのかな。
- 輝度が高過ぎるものや低すぎる(0.3以下、あるいは0.9以上)の色を除外
- UVで中心に寄りすぎているもの(±0.05以下)は白黒に近いので除外
- 彩度が低すぎ(0.3以下)の色もの特徴がなさすぎるので除外
で、その情報からNSMutableDictionaryを作る。
1 2 3 4 5 6 |
NSMutableDictionary *newColor = [NSMutableDictionary dictionaryWithDictionary: @{ @"y": @(y), @"u" : @(u), @"v" : @(v), @"r" : @(red), @"g" : @(green), @"b" : @(blue) } ]; |
なんかすごい。NSDictionaryやNSArrayが@{}や@[]で書けるようになったのは知ってましたが、@(y)とかって書くとNSNumberに変換できるって知りませんでした。1年位前から出来たっぽい。
続いてはなんかすごい冗長な感じでもあるけどやってることはcolorBucketsっていう配列(NSMutableArray)に格納されている色情報と比較してYUVをピタゴラスの定理から2つの色の色差を計算して、それが近い(0.1未満)だった場合はほぼ同じ色と判断して、これまでチェックした色との平均をとって新しい色とします。全然違った色だった場合は新しく要素を配列に追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
BOOL addedColor = NO; if (colorBuckets.count > 0) { for (NSMutableArray *bucket in colorBuckets) { //Find Distance NSMutableDictionary *bColor = [bucket objectAtIndex:0]; float u2 = [[bColor objectForKey:@"u"] floatValue]; float v2 = [[bColor objectForKey:@"v"] floatValue]; float y2 = [[bColor objectForKey:@"y"] floatValue]; float distance = sqrt(pow(u2 - u, 2) + pow(v2 - v, 2) + pow(y2 - y, 2)); if (distance < 0.1) { // Weight first Average float count = bucket.count; [bColor setObject:@([self weightedAverage:[[bColor valueForKey:@"r"] floatValue] count:count newNumber:red]) forKey:@"r"]; : (省略) [newColor setObject:@([self weightedAverage:[[bColor valueForKey:@"r"] floatValue] count:count newNumber:red]) forKey:@"r"]; : (省略) [bucket addObject:newColor]; addedColor = YES; break; } } } |
colorBucketsの各要素はまた配列(NSMutableArray)になっていて、類似色だった場合に要素が追加されて、結果としてその数が類似色の数になって、類似色が一番多かった際にその平均の色が画像の特徴色として取り出される仕組みです。
colorBucketsの各要素の配列の先頭は類似色の平均(weightedAverageというメソッドで計算される)が入ります。bColorという変数の値として計算されています。一方で新しい要素を追加する際にも何やら取得した色とbColorの平均をとってnewColorを作っていますが、これ意味あるのかな。使ってない気がするけど…
1 2 3 |
if (!addedColor) { [colorBuckets addObject:[NSMutableArray arrayWithObject:newColor]]; } |
類似色がなかった場合はcolorBucketsに新しい色として追加します。そして要素数でソートします。
1 2 3 |
[colorBuckets sortUsingComparator:^NSComparisonResult(NSArray *obj1, NSArray *obj2) { return obj1.count >= obj2.count ? NSOrderedAscending : NSOrderedDescending; }]; |
そして最後にcolorBucketsをチェックしていますが何やらここにもマジックナンバーの香りが。色を出現数の多い順にチェックしますが、特定のUV領域の色が出現した場合は次の色を入れる的な処理が入っています。領域的にはUがマイナスでVが0から0.26の間。緑からオレンジ色的な領域にかけて?かと思いますが、これはUIに合わないから削った?のかまだ不明です。あとpopColとpopularColorとか変数名が微妙に気になります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
UIColor *popularColor = [UIColor whiteColor]; if (colorBuckets.count) { NSDictionary *popCol = [[colorBuckets objectAtIndex:0] objectAtIndex:0]; for (NSArray *bucket in colorBuckets) { NSMutableDictionary *bColor = [bucket objectAtIndex:0]; float u = [[bColor objectForKey:@"u"] floatValue]; float v = [[bColor objectForKey:@"v"] floatValue]; if (!(u < 0 && v < 0.26 && v > -0)) { popCol = bColor; break; } } popularColor = [UIColor colorWithRed:[[popCol objectForKey:@"r"] floatValue] green:[[popCol objectForKey:@"g"] floatValue] blue:[[popCol objectForKey:@"b"] floatValue] alpha:1]; } |
で、最後に取り出した色をUIColorに変換して(もとのRGBを利用)、メインスレッドにてコールバック関数を呼び出しています。
1 2 3 4 5 |
dispatch_async(dispatch_get_main_queue(), ^{ if (completionBlock) { completionBlock(popularColor); } }); |
ということでここまで読んでわかったことは以下のとおりでした。
- 画像を縮小して1ピクセルずつ調べることで特徴色を出している
- RGBをYUVという色空間に変換することで、色の差を計算して類似色をまとめている
- コードはかなり改善の余地がある(コピペコードとかマジックナンバーとか…)
ついでに調べていたんですけども、画像を特徴色を調べるを読んでいて、複数の色を出すならきちんとこちらのようなアルゴリズムを使ったほうがいいですし、上記アルゴリズムだと黄色の中に赤1点みたいなのは出ないなあと思いましたけど、1色だけ選ぶんならこれでいい…のかもですね。