今回は下記書籍のk平均法による画像の減色処理のコードを Ruby で実装してみたいと思います。
ITエンジニアのための機械学習理論入門
https://www.amazon.co.jp/IT-ebook/dp/B016Q22IX2/
サンプルコードはこちらで公開されています。
画像ファイルの読み込み
Python版ではPILライブラリのImageクラスを使って画像ファイルを読み込み、RGBデータとして取得して配列にしています。
The Python Imaging Library Handbook
from PIL import Image im = Image.open("photo.jpg") pixels = list(im.convert('RGB').getdata())
Ruby版では ImageMagick をインストールし、RMagick を使って画像を扱いました。
RMagick 2.12.0 User's Guide and Reference
https://rmagick.github.io/image2.html#export_pixels
下記サイトを参考にさせていただき、 export_pixels メソッドで取得した 16bit のRGBデータを 8bit に変換しました。
irb(main):001:0> require 'rmagick' => true irb(main):002:0> img = Magick::ImageList.new('./photo.jpg') => [./photo.jpg JPEG 520x346 520x346+0+0 DirectClass 8-bit 46kb] scene=0 irb(main):003:0> img.export_pixels.map {|p| p / 257 }.each_slice(3).to_a
乱数の取得
Python版ではランダムな整数を取得するために numpy.random.randint を使っています。
from numpy.random import randint randint(256)
numpy.random.randint — NumPy v1.12 Manual
このメソッドは引数に渡した整数未満のランダムな整数を返すと言うことで、引数もその範囲に含む random.randint とは動きが異なるようです。
Ruby版では Random#rand メソッドが同様の挙動になります。
random = Random.new random.rand(256)
ゼロ徐算によるエラーの回避
Python版ではk平均法によって新しい代表点を求める際に、下記のような計算を行なっています。
for i in range(k): center_new[i] = center_new[i] / num_points[i]
このとき、特にクラスタ数が多いケースだと、あるクラスタに属する点がないときに num_points[i] が 0 になり、ゼロ徐算が発生します。 center_new[i] は ndarray のオブジェクトですが、通常の数値計算のゼロ徐算だとエラーになるのに対し、ndarrayに対するゼロ徐算はWarningで、エラーにはならないようです。
>>> np.array([100, 100, 100]) / 0 __main__:1: RuntimeWarning: divide by zero encountered in divide array([0, 0, 0])
Ruby では Numo::NArray に対するゼロ徐算はエラーになります。
irb(main):085:0* Numo::NArray[100,100,100] / 0 ZeroDivisionError: error in NArray operation from (irb):85:in `/' from (irb):85 from /usr/local/rbenv/versions/2.3.1/bin/irb:11:in `<main>'
このエラーを回避するために、 num_points[i] が 0 の場合は、 Numo::NArray[0, 0, 0] を返すようにしました。
k.times do |i| if num_points[i] == 0 center_new[i] = Numo::NArray[0, 0, 0] next end center_new[i] = center_new[i] / num_points[i] end
画像ファイルの変更、保存
Python版では読み込み時と同様にPILを使用し、元の画像にputdataで計算結果を反映し、ビットマップ形式の別ファイルとして保存しています。
im.putdata(result) # Update image im.save("output%02d.bmp" % k, "BMP")
Ruby版では Magick::Image#import_pixels で計算結果を反映して、 Magick::Image#write で別ファイルとして保存しました。読み込みの時に export_pixels で取得した配列データを 8bit に変換してRGB区切りの配列データにしたので、それとは逆に、一次元配列に戻して16bitに変換したものを渡しています。
img.import_pixels(0, 0, img.columns, img.rows, "RGB", result.flatten.map {|p| p * 257 }) img.write("bmp:output%02d.bmp" % k)
https://rmagick.github.io/image2.html#import_pixels
https://rmagick.github.io/image3.html#write
スクリプト全体
ここまでの内容を踏まえて、コード全体としては下記のように実装しました。
# k平均法による画像の減色処理 require 'rmagick' require 'numo/narray' Colors = [2, 3, 5, 16] # 減色後の色数 # k平均法による減色処理 def run_kmeans(pixels, k) cls = Array.new(pixels.size).fill(0) # 代表色の初期値をランダムに設定 random = Random.new center = k.times.map { Numo::NArray[random.rand(256), random.rand(256), random.rand(256)] } puts "Initial centers: #{center.map(&:to_a)}" puts "========================" distortion = 0.0 # 最大50回のIterationを実施 50.times do |iter_num| center_new = Array.new(k).fill(Numo::NArray[0, 0, 0]) num_points = Array.new(k).fill(0) distortion_new = 0.0 # E Phase: 各データが属するグループ(代表色)を計算 pixels.each_with_index do |point, pix| min_dist = 256 * 256 * 3 point = Numo::NArray[*point] k.times do |i| d = (point - center[i]).cast_to(Numo::Int64).to_a.inject(0) {|sum, x| sum + (x ** 2) } if d < min_dist min_dist = d cls[pix] = i end center_new[cls[pix]] += point num_points[cls[pix]] += 1 distortion_new += min_dist end end # M Phase: 新しい代表色を計算 k.times do |i| if num_points[i] == 0 center_new[i] = Numo::NArray[0, 0, 0] next end center_new[i] = center_new[i] / num_points[i] end center = center_new puts center.map(&:to_a).inspect puts "Distortion: J=#{distortion_new}" # Distortion(J)の変化が0.1%未満になったら終了 if iter_num > 0 && distortion - distortion_new < distortion * 0.001 break end distortion = distortion_new end # 画像データの各ピクセルを代表色で置き換え pixels.each_with_index do |point, pix| pixels[pix] = center[cls[pix]].to_a end pixels end Colors.each do |k| puts "" puts "========================" puts "Number of clusters: K=#{k}" # 画像ファイルの読み込み img = Magick::ImageList.new('./photo.jpg') pixels = img.export_pixels.map {|p| p / 257 }.each_slice(3).to_a # k平均法による減色処理 result = run_kmeans(pixels, k) img.import_pixels(0, 0, img.columns, img.rows, "RGB", result.flatten.map {|p| p * 257 }) img.write("bmp:output%02d.bmp" % k) end
これを実行するとターミナル上では下記のように、初期のランダムな代表点と、計算ごとに変化していく代表点と二乗歪み(J)の値が出力されます。
$ ruby 06-k_means.rb ======================== Number of clusters: K=2 Initial centers: [[73, 209, 26], [101, 193, 89]] ======================== [[147, 136, 114], [177, 155, 141]] Distortion: J=11686169170.0 [[140, 126, 102], [231, 210, 215]] Distortion: J=5777512344.0 [[143, 126, 103], [237, 225, 228]] Distortion: J=4710119998.0 [[143, 126, 104], [238, 228, 231]] Distortion: J=4670715192.0 [[143, 126, 104], [238, 228, 231]] Distortion: J=4668708398.0 ======================== Number of clusters: K=3 Initial centers: [[214, 192, 131], [132, 203, 96], [118, 69, 71]] ======================== [[193, 169, 164], [99, 112, 65], [104, 83, 52]] Distortion: J=8555090758.0 [[192, 173, 165], [102, 106, 61], [105, 55, 41]] Distortion: J=6458497492.0 [[192, 173, 165], [101, 104, 60], [110, 46, 40]] Distortion: J=6391712615.0 [[192, 173, 165], [100, 103, 59], [120, 41, 42]] Distortion: J=6384285256.0 [[192, 173, 165], [96, 101, 56], [153, 33, 52]] Distortion: J=6369689981.0 [[191, 173, 165], [94, 100, 55], [182, 35, 64]] Distortion: J=6295123108.0 [[191, 174, 165], [93, 99, 54], [185, 38, 68]] Distortion: J=6267370834.0 [[191, 174, 165], [93, 99, 54], [186, 40, 70]] Distortion: J=6265803130.0 ======================== Number of clusters: K=5 Initial centers: [[194, 128, 53], [20, 175, 19], [143, 69, 162], [10, 6, 13], [232, 145, 213]] ======================== [[164, 135, 117], [67, 100, 43], [219, 196, 202], [49, 63, 25], [235, 218, 223]] Distortion: J=17277755636.0 [[174, 141, 131], [84, 99, 48], [235, 220, 224], [71, 50, 25], [245, 243, 244]] Distortion: J=7740485201.0 [[179, 148, 140], [91, 101, 52], [237, 227, 230], [89, 45, 29], [250, 249, 249]] Distortion: J=7006558605.0 [[182, 151, 144], [94, 102, 54], [238, 230, 233], [103, 42, 34], [251, 251, 251]] Distortion: J=6813616639.0 [[183, 153, 146], [94, 102, 54], [239, 232, 234], [118, 38, 38], [252, 252, 252]] Distortion: J=6752640626.0 [[183, 154, 147], [90, 99, 52], [239, 232, 235], [164, 29, 51], [252, 252, 252]] Distortion: J=6699438034.0 [[182, 155, 147], [89, 98, 52], [239, 233, 235], [181, 32, 61], [252, 252, 252]] Distortion: J=6536049096.0 [[182, 156, 147], [89, 98, 51], [239, 233, 235], [183, 35, 64], [252, 252, 252]] Distortion: J=6513101512.0 [[182, 156, 147], [89, 98, 51], [239, 233, 235], [184, 36, 65], [252, 252, 252]] Distortion: J=6510842397.0 ======================== Number of clusters: K=16 Initial centers: [[240, 182, 207], [150, 87, 15], [121, 236, 188], [140, 204, 22], [66, 131, 79], [157, 120, 116], [17, 196, 63], [164, 6, 24], [34, 40, 245], [45, 113, 4], [229, 65, 229], [117, 158, 111], [114, 36, 146], [51, 62, 230], [ 48, 35, 252], [14, 78, 55]] ======================== [[225, 210, 211], [118, 82, 56], [151, 171, 141], [124, 151, 86], [82, 111, 55], [176, 118, 115], [0, 0, 0], [176, 22, 51], [0, 0, 0], [49, 76, 26], [234, 96, 155], [125, 150, 99], [131, 57, 81], [0, 0, 0], [0, 0, 0], [38, 63, 27]] Distortion: J=17994009460.0 [[226, 215, 214], [117, 78, 55], [169, 157, 139], [116, 141, 83], [78, 107, 48], [201, 99, 122], [24, 49, 15], [180, 25, 54], [0, 0, 0], [46, 72, 26], [224, 103, 146], [121, 147, 105], [153, 62, 72], [0, 0, 0], [0, 0, 0], [32, 57, 20]] Distortion: J=12774225380.0 [[226, 216, 215], [115, 77, 53], [176, 153, 141], [112, 137, 80], [75, 105, 46], [210, 84, 119], [31, 57, 19], [179, 23, 52], [9, 21, 8], [49, 74, 28], [226, 116, 157], [125, 150, 107], [163, 67, 73], [0, 0, 0], [0, 0, 0], [33, 59, 20]] Distortion: J=12102951219.0 [[226, 217, 216], [114, 77, 53], [181, 150, 142], [110, 136, 78], [74, 103, 44], [211, 74, 114], [34, 59, 20], [177, 22, 49], [14, 35, 10], [51, 76, 29], [226, 129, 164], [129, 152, 106], [168, 70, 77], [0, 0, 0], [0, 0, 0], [36, 62, 21]] Distortion: J=11926470826.0 [[226, 217, 216], [113, 78, 53], [184, 148, 143], [109, 135, 77], [73, 102, 43], [210, 68, 109], [35, 61, 21], [174, 21, 47], [18, 41, 12], [52, 78, 30], [227, 135, 168], [131, 152, 106], [170, 73, 79], [0, 0, 0], [0, 0, 0], [39, 65, 21]] Distortion: J=11849083461.0 [[226, 217, 216], [112, 79, 53], [186, 146, 144], [108, 135, 77], [72, 101, 43], [209, 64, 105], [37, 62, 21], [172, 20, 45], [20, 45, 13], [53, 79, 31], [227, 137, 170], [132, 152, 107], [169, 76, 80], [9, 18, 5], [0, 0, 0], [42, 67, 21] ] Distortion: J=11816838760.0 [[226, 217, 216], [111, 80, 53], [188, 145, 144], [108, 135, 77], [72, 101, 42], [208, 60, 102], [38, 63, 22], [171, 19, 44], [22, 47, 14], [54, 79, 32], [227, 138, 171], [133, 152, 107], [168, 80, 81], [10, 27, 9], [0, 0, 0], [44, 69, 21 ]] Distortion: J=11800409417.0 [[226, 217, 216], [110, 81, 53], [189, 143, 144], [108, 135, 77], [71, 101, 42], [207, 58, 99], [39, 64, 22], [170, 19, 43], [23, 48, 14], [54, 79, 32], [228, 138, 171], [134, 153, 108], [167, 84, 82], [12, 33, 9], [0, 0, 0], [46, 71, 20] ] Distortion: J=11789721844.0
そして下記の元画像に対して、代表色を2色、3色、5色、16色に減色した画像が生成されます。元画像はPython版のサンプルコードと一緒に公開されているものをそのまま使わせていただきましたが、生成後の画像もPython版と同じようなものが生成できたので、問題なさそうです。
オリジナル画像
2色
3色
5色
16色
コードは下記にも公開しました。