はじめに
今回も前回に引き続き自作プログラムでベーシックな画像処理まがいのことをしていきます.
今回のお品書きはこちらです.
- カラーヒストグラム表示
- RGB→HSV変換とHSV→RGBのマッピング
- トーンカーブ機能の作成
その1. カラーヒストグラムの表示
RGB 3チャンネルではなく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 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| pub(crate) fn show_hist_color(&self, ui: &Ui, pos: [f32; 2], size: [f32; 2]) { let vmin = 0.0; let vmax = 65535.0;
const FILL_COLOR_R: u32 = 0x5500_00FF; const FILL_COLOR_G: u32 = 0x5500_FF00; const FILL_COLOR_B: u32 = 0x55FF_0000; const BORDER_COLOR: u32 = 0xFF00_0000; let hist = self.image.hist_color(); if let Some((max_count_r, max_count_g, max_count_b)) = hist .iter() .map(|bin| (bin[0].count, bin[1].count, bin[2].count)) .max() { let draw_list = ui.get_window_draw_list(); let max_count = max_count_r.max(max_count_g).max(max_count_b); let x_pos = pos[0]; for i in 0..3 { for bin in hist { let y_pos = pos[1] + size[1] / (vmax - vmin) * (vmax - bin[i].start); let y_pos_end = pos[1] + size[1] / (vmax - vmin) * (vmax - bin[i].end); let length = size[0] * if self.hist_logscale { (bin[i].count as f32).log10() / (max_count as f32).log10() } else { (bin[i].count as f32) / (max_count as f32) }; draw_list .add_rect( [x_pos + size[0] - length, y_pos], [x_pos + size[0], y_pos_end], if i == 0 { FILL_COLOR_R } else if i == 1 { FILL_COLOR_G } else { FILL_COLOR_B }, ) .filled(true) .build(); } } draw_list .add_rect(pos, [pos[0] + size[0], pos[1] + size[1]], BORDER_COLOR) .build(); } }
|
するとこんな感じにカラー画像の横に出てきます.

その2. RGB→HSV変換
このサイトによると,画素の RGB それぞれの輝度値$$R, G, B$$として,
$$Max = \max(R, G, B), Min = \min(R, G, B)$$
$$H = 60 * ((G - B) / (Max - Min)) ,; \max(R, G, B) = R$$
$$H = 60 * ((B - R) / (Max - Min)) + 120 ,; \max(R, G, B) = G$$
$$H = 60 * ((R - G) / (Max - Min)) + 240 ,; \max(R, G, B) = B$$
$$S = (Max - Min) / Max$$
$$V = Max$$
と計算できるっぽいのでこれに習っていきます.上の例だと色相は360度で表現してますが,後々 RGB に再マッピングすることを考えて 16bit の範囲に再スケールしておきます.
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 29 30 31 32
| fn run_color_image_to_hsv(image: &WcsArray) -> Result<IOValue, IOErr> { dim_is!(image, 3)?; let image = image.scalar(); let mut out = image.clone(); for (j, slice) in image.axis_iter(Axis(1)).enumerate() { for (i, data) in slice.axis_iter(Axis(1)).enumerate() { let mut hue = 0.0; let max = data[0].max(data[1]).max(data[2]); let min = data[0].min(data[1]).min(data[2]); if data[0] >= data[1] && data[0] >= data[2] { hue = 60.0 * ((data[1] - data[2]) / (max - min)); } else if data[1] >= data[0] && data[1] >= data[2] { hue = 60.0 * ((data[2] - data[0]) / (max - min)) + 120.0; } else if data[2] >= data[0] && data[2] >= data[1] { hue = 60.0 * ((data[0] - data[1]) / (max - min)) + 240.0; } else { println!("Unreachable"); } if hue < 0.0 { hue += 360.0; } hue = hue / 360.0 * 65535.0; out[[0, j, i]] = hue; out[[1, j, i]] = (max - min) / max * 65535.0; out[[2, j, i]] = max; } } Ok(IOValue::Image(WcsArray::from_array(Dimensioned::new( out, Unit::None, )))) }
|
まずは HSV の値が入っている三次元データをスライスして見てみるとこんな感じの強度分布図になります.

上のビジュアルプログラムについて少し説明を書いておくと,まず上記の計算式から 0番目に色相のマップ,1番目に彩度のマップ,2番目に明度のマップが入っているような三次元データを作ります(図の赤枠).この三次元データは右側に流れていき,スライスすることでそれぞれのマップデータ(二次元)を得ています.
次にこれらをRGBに再マッピングしてみます.次のような,三つの二次元のチャンネルデータを受け取ってカラー画像(三次元データ)を出力するようなノードを作ります.
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 29
| fn run_generate_color_image_from_channel( image_r: &WcsArray, image_g: &WcsArray, image_b: &WcsArray, ) -> Result<IOValue, IOErr> { dim_is!(image_r, 2)?; dim_is!(image_g, 2)?; dim_is!(image_b, 2)?; are_same_dim!(image_r, image_b)?; are_same_dim!(image_b, image_g)?; let dim = image_r.scalar().dim(); let dim = dim.as_array_view(); let mut colorimage = Vec::with_capacity(3 * dim[0] * dim[1]); for &data in image_r.scalar().iter() { colorimage.push(data); } for &data in image_g.scalar().iter() { colorimage.push(data); } for &data in image_b.scalar().iter() { colorimage.push(data); } let img = Array::from_shape_vec((3, dim[0], dim[1]), colorimage).unwrap();
Ok(IOValue::Image(WcsArray::from_array(Dimensioned::new( img.into_dyn(), Unit::None, )))) }
|
とりあえずHをR,SをG,VをBにそのままマッピングすると次のようになりました.エッジの接続関係(下図の黄緑枠)を変えることで,別の組み合わせにする(たとえばHをB,SをR,VをGなど)ことも自由自在です.

上の図で,HをR,SをG,VをBに接続していることが表現されています.
トーンカーブの作成
お次はトーンカーブ.ゼロから作らなければならなかったので,これが一番面倒でした.まずはインタフェースからのんびり作っていきます.
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 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134
| #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct ToneCurveState { arr: Vec<f32>, cp: Vec<[f32; 2]>, adding: Option<[f32; 2]>, pushed: bool, moving: Option<usize>, deleting: Option<usize>, x_clicking: usize, is_dragging: bool, }
impl ToneCurveState { pub fn default() -> Self { }
pub fn control_points(self) -> Vec<[f32; 2]> { self.cp }
pub fn array(self) -> Vec<f32> { self.arr } }
impl PartialEq for ToneCurveState { fn eq(&self, val: &Self) -> bool { let cp1 = self.control_points(); let cp2 = val.control_points(); cp1 == cp2 } }
pub trait UiToneCurve { fn create_curve(cp: &Vec<[f32; 2]>) -> Vec<f32>; fn tone_curve( &self, state: &mut ToneCurveState, draw_list: &WindowDrawList, ) -> io::Result<Option<ToneCurveState>>; }
impl<'ui> UiToneCurve for Ui<'ui> { fn create_curve(cp: &Vec<[f32; 2]>) -> Vec<f32> { }
fn tone_curve( &self, state: &mut ToneCurveState, draw_list: &WindowDrawList, ) -> io::Result<Option<ToneCurveState>> { let p = self.cursor_screen_pos(); let mouse_pos = self.io().mouse_pos; let [mouse_x, mouse_y] = [mouse_pos[0] - p[0] - 5.0, mouse_pos[1] - p[1] - 5.0]; state.arr = Self::create_curve(&state.cp); self.invisible_button(im_str!("tone_curve"), [410.0, 410.0]); self.set_cursor_screen_pos(p); PlotLines::new(self, im_str!("Tone Curve Test"), &state.arr) .graph_size([410.0, 410.0]) .scale_min(0.0) .scale_max(256.0) .build(); self.set_cursor_screen_pos(p); self.invisible_button(im_str!("tone_curve"), [410.0, 410.0]); if let Some(adding) = state.adding { let x = adding[0] * 400.0 + 5.0 + p[0]; let y = (1.0 - adding[1]) * 400.0 + 5.0 + p[1]; draw_list.add_circle([x, y], 5.0, 0xFF00_FFFF).build(); } let mut counter = 0; for i in &state.cp { let x = i[0] * 400.0 + 5.0 + p[0]; let y = (1.0 - i[1]) * 400.0 + 5.0 + p[1]; if (x - mouse_pos[0]) * (x - mouse_pos[0]) + (y - mouse_pos[1]) * (y - mouse_pos[1]) < 25.0 { draw_list.add_circle([x, y], 5.0, 0xFF00_00FF).build(); if self.is_mouse_clicked(MouseButton::Left) && state.adding == None { state.moving = Some(counter); } if self.is_mouse_clicked(MouseButton::Right) { self.open_popup(im_str!("delete-control-point")); state.deleting = Some(counter); } } else { draw_list.add_circle([x, y], 5.0, 0xFFFF_FFFF).build(); } counter += 1; } self.popup(im_str!("delete-control-point"), || { if MenuItem::new(im_str!("Delete Control Point")).build(self) { if let Some(key) = state.deleting { state.cp.remove(key); state.deleting = None; } } }); if self.is_item_hovered() { if self.is_mouse_clicked(MouseButton::Left) && state.moving == None { if !state.is_dragging { state.is_dragging = true; } } if state.is_dragging { if state.pushed == false { state.pushed = true; state.cp.push([mouse_x / 400.0, (400.0 - mouse_y) / 400.0]); } state.adding = Some([mouse_x / 400.0, (400.0 - mouse_y) / 400.0]); let lastidx = state.cp.len() - 1; state.cp[lastidx] = state.adding.unwrap(); if state.x_clicking > 255 { state.x_clicking = 255; } if !self.is_mouse_down(MouseButton::Left) { state.cp.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap()); state.adding = None; state.is_dragging = false; state.pushed = false; } } if let Some(key) = state.moving { state.cp[key] = [mouse_x / 400.0, (400.0 - mouse_y) / 400.0]; if !self.is_mouse_down(MouseButton::Left) { state.moving = None; } } } let state = state.clone(); Ok(Some(state)) } }
|
まだハードコードされているところとかがあり,要修正ですが貼っておきます.曲線の計算には現在 Catmull-Rom Spline 曲線を使っています.state にはコントロールポイントやルックアップテーブルの配列データ(現在大きさは256,0.0~256.0までの値が格納)が入っていて,次のように使います.
1 2 3 4 5 6
| let tone_curve_state = ui.tone_curve(&mut state, &draw_list); if let Ok(state) = tone_curve_state { let state = state.unwrap(); let control_points = state.control_points(); let array = state.array(); }
|
次に,トーンカーブのデータ型を作って,画像データとトーンカーブデータから適用後の画像を出力するノードを作って完成です.
トーンカーブデータのところは省略します.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| fn run_apply_tone_curve(image: &WcsArray, tone_curve: ToneCurveState) -> Result<IOValue, IOErr> { let mut image_arr = image.scalar().clone(); let table = tone_curve.array(); let table_size = table.len() - 1; image_arr.par_map_inplace(|v| { let key = (*v * table_size as f32 / 65535.0) as usize; let value = 65535.0 * table[key] / table_size as f32; *v = value; }); Ok(IOValue::Image(WcsArray::from_array(Dimensioned::new( image_arr, Unit::None, )))) }
|
ちなみに par_map_inplace という関数は ndarray_parallel という crate にあるもので,中の計算を並列処理してくれます.
普通に map で計算する場合と時間を比較するとこんな感じです.
画像にて失礼….
実行結果はこんな漢字です.
結構速くなってますね.他にも直せるところがあると思っています.
さて,数日かけて色々とやりましたが次は何をしましょうか.悩み中です.しばらくは改善とリファクタリングですかね….
なにか作って欲しいものやアドバイスがありましたらブログや Twitter で募集しております.
それでは.