自作プログラムで画像処理(カラーヒストグラム表示~トーンカーブ作成まで)

目次

  1. 1. はじめに
    1. 1.1. その1. カラーヒストグラムの表示
    2. 1.2. その2. RGB→HSV変換
    3. 1.3. トーンカーブの作成

はじめに

今回も前回に引き続き自作プログラムでベーシックな画像処理まがいのことをしていきます.
今回のお品書きはこちらです.

  • カラーヒストグラム表示
  • 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 それぞれの輝度値として,

と計算できるっぽいのでこれに習っていきます.上の例だと色相は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 の値が入っている三次元データをスライスして見てみるとこんな感じの強度分布図になります.


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など)ことも自由自在です.


HSV→RGBへのマッピング
上の図で,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 で募集しております.
それでは.