Rustとeguiで画像にモザイクをかけるツールを作った

こんちは、最近寒いっすね。

先週の休日に突然"画像にモザイクをかけるツール"が欲しくなったので作りました。
Rustとeguiの組み合わせでツールを作る記事はあまりネットにないので、このツールを作るためにどんな機能をeguiでどうやって実現したか、他の誰かの参考になればいいかなと思ってまとめました。

できたもの

before after

使ったライブラリなど

  • Rust 2021
  • Rustのライブラリ
    • egui
    • eframe
    • image
    • rfd

機能

  • 画面表示
  • 画像入力
  • マウスドラッグによる範囲指定
  • モザイク化
  • 画像出力

画面表示

eguiを使って画面表示するには以下のコードが必要でした。

fn main() -> eframe::Result {
    let options = eframe::NativeOptions {
        viewport: egui::ViewportBuilder::default().with_inner_size([320.0, 240.0]),
        ..Default::default()
    };

    eframe::run_native( // 1️⃣
        "Image Mosaic Editor",
        options,
        Box::new(|cc| {
            // This gives us image support:
            egui_extras::install_image_loaders(&cc.egui_ctx);
            Ok(Box::new(MyEguiApp::new(cc)))
        }),
    )
}

struct MyEguiApp {/* */}

impl MyEguiApp {
    fn new(context: &eframe::CreationContext<'_>) -> Self {
        let mut fonts = FontDefinitions::default();
        fonts.font_data.insert(
            "my_font".to_owned(),
            std::sync::Arc::new(
                // .ttf and .otf supported
                FontData::from_static(include_bytes!("../NotoSansJP-Regular.ttf")),
            ),
        );
        fonts
            .families
            .get_mut(&FontFamily::Proportional)
            .unwrap()
            .insert(0, "my_font".to_owned());

        fonts
            .families
            .get_mut(&FontFamily::Monospace)
            .unwrap()
            .push("my_font".to_owned());
        context.egui_ctx.set_fonts(fonts);
        Self {
            image: None,
            mosaic_center_distance_pixels: NonZeroU32::new(5).unwrap(),
            trim_frame: TrimFrame {
                left: 0,
                top: 0,
                right: 0,
                bottom: 0,
            },
        }
    }
}

impl eframe::App for MyEguiApp {
    fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
      // ...
    }
}

1️⃣. eframerun_native()を実行することで画面表示を開始しています。
eframeには他にもブラウザで実行可能にするためのWebRunnerが用意されているようですが、WASMを扱う必要があり主旨から逸れるため使いませんでした。

画像入力

画像を入力するためには画像を選択するダイアログを表示して、選択された画像をプログラム上で保存する必要があります。

この機能を実現するために、rfdを使いました。
rfdはWindowsやMacなど複数のクライアントで動作可能ですし、OSネイティブなダイアログが表示されるため使い勝手が良かったです。
使い方もこんな感じでとても簡単です

use rfd::FileDialog;

let files = FileDialog::new()
    .add_filter("text", &["txt", "rs"])
    .add_filter("rust", &["rs", "toml"])
    .set_directory("/")
    .pick_file();

https://docs.rs/rfd/latest/rfd/#synchronous

この処理をeguiのボタンが押されたタイミングで実行し、返却されたファイルパスをもとに画像ファイルを保存させています。

  if ui.button(" 画像を開く").clicked() {
    if let Some(path) = rfd::FileDialog::new().pick_file() {
        let decoder = ImageReader::open(path.display().to_string().clone())
            .unwrap()
            .into_decoder()
            .unwrap();

        let _image = DynamicImage::from_decoder(decoder).unwrap();  // 1️⃣
        let file_name = path.file_name().unwrap().to_string_lossy().to_string();
        let bytes = get_bytes(&_image, &file_name); // 2️⃣

        self.image = Some(TargetImage {
            raw_file_name: file_name,
            saving_image: _image.clone(),
            processing_image: _image,
            selected_area: None,
            cached_bytes: bytes,
        });

        // ...
    }
  }

  // ...

  let image_widget = Image::from_bytes(uri.clone(), image.cached_bytes.clone()); // 2️⃣

1️⃣. 取得した画像ファイルはDynamicImageとして取り扱います。
DynamicImageはpngやjpgやgifなどさまざまなフォーマットの画像を取り扱えるのでツールとしてもそれらのフォーマットすべてに対応することができます。
RustにはDynamicImageの他にGenericImageも用意されているらしいです。

Rustのimageクレートでは、Bitmapにおけるピクセルフォーマットの扱いが複雑で、ピクセルフォーマットをコンパイル時に確定する GenericImage とピクセルフォーマットを実行時に確定する DynamicImage に分かれています。

Rustで画像処理 メリットと注意点

2️⃣. eguiで画像を表示する際には、URI, バイトデータ, Textureのいずれかの形式で読み込ませる必要があります。
画面表示ごとにDynamicImageをバイトデータに変換すると処理遅延が起きてしまうため、画像が入力されたタイミングでバイトデータに変換させています。

eguiには他にもegui-file-dialogという似たライブラリがありますが、OSネイティブではなく独自デザインのダイアログだったため、少し使いにくい印象を受けました。

マウスドラッグによる範囲指定

egui上で表示された画像をユーザーがドラッグすると赤いフレームが表示され、ドラッグを終えると赤いフレームを確定しその範囲にモザイクをかける必要があります。

let image_widget_response = ui.add(image_widget.max_height(500.));
let drag_res = image_widget_response.interact(Sense::drag()); // 1️⃣

if drag_res.drag_started() { // 1️⃣
    image.selected_area = Some(Area {
        start_pos: drag_res.interact_pointer_pos.unwrap() // 2️⃣
            - drag_res.interact_rect.left_top(),
        end_pos: drag_res.interact_pointer_pos.unwrap()
            - drag_res.interact_rect.left_top(),
    });
}
if drag_res.dragged() { // 1️⃣
    if let Some(area) = &mut image.selected_area {
        area.end_pos = drag_res.interact_pointer_pos.unwrap() // 2️⃣
            - drag_res.interact_rect.left_top();
    }
}

// ...

if let Some(area) = &mut image.selected_area {
    ui.painter().rect( // 3️⃣
        Rect::from_two_pos(
            drag_res.rect.left_top() + area.start_pos,
            drag_res.rect.left_top() + area.end_pos,
        ),
        Rounding::ZERO,
        Color32::TRANSPARENT,
        Stroke::new(1., Color32::RED),
    );
}

1️⃣. ウィジェットに対して.interact(Sense::drag())を実行し、ドラッグ状態を(drag_res)を取得します。
drag_resをもとにドラッグ開始時/ドラッグ中の処理を記述していきます。記載はないですがドラッグ終了時も検知できます。

2️⃣. ドラッグ開始時/ドラッグ中には赤いフレームの位置情報を更新し、

3️⃣. ui.painter().rect()で赤いフレームを表す四角形を画面に表示しています

モザイク化

ユーザーがドラッグを終えると範囲内をモザイク化します。
main.rsにインラインで書いちゃいましたが、ファイル分ければよかったなぁと思ってます

if drag_res.drag_stopped() {
  if let Some(area) = &mut image.selected_area {
      // 画像の加工
      let mut proccesing_image = image.saving_image.clone();
      let radius: u32 = self.mosaic_center_distance_pixels.get();
      let diameter = radius * 2 + 1;

      let (width, height) = proccesing_image.dimensions(); // 1️⃣
      let ratio = get_image_ratio(&proccesing_image, image_widget_response.rect);

      let min_pos = area.start_pos.min(area.end_pos);
      let max_pos = area.start_pos.max(area.end_pos);

      let mut pixel_cache: HashMap<(u32, u32), Rgba<u8>> = HashMap::new(); // 3️⃣
      for x in 0..width { // 1️⃣
          for y in 0..height { // 1️⃣
              let a = Pos2::new(x as f32, y as f32) - (ratio * min_pos);
              let b = (-1. * Pos2::new(x as f32, y as f32)) + (ratio * max_pos);
              if a.x > 0. && a.y > 0. && b.x > 0. && b.y > 0. { // 1️⃣
                  if x % diameter != radius || y % diameter != radius { // 1️⃣
                      let center_x = x - (x % diameter) + radius;
                      let center_y = y - (y % diameter) + radius;
                      if center_x < width && center_y < height {
                          let pixel = match pixel_cache.get(&(center_x, center_y))
                          {
                              Some(a) => a.clone(),
                              None => {
                                  let _pixel = proccesing_image
                                      .get_pixel(center_x, center_y); // 2️⃣
                                  pixel_cache
                                      .insert((center_x, center_y), _pixel); // 3️⃣
                                  _pixel
                              }
                          };

                          proccesing_image.put_pixel(x, y, pixel); // 2️⃣
                      }
                  }
              }
          }
      }
      image.cached_bytes = get_bytes(&proccesing_image, &image.raw_file_name);
      image.processing_image = proccesing_image;
  }
}

1️⃣. 画像の縦横を取得して愚直にループを回します。ループの中では画像の範囲内か/フレームの範囲内かをチェックしてます

2️⃣. モザイク処理は単純にある大きさの正方形の領域を同じ色に設定することで実現が可能です。なので正方形の領域の中心の色(勝手に"領域中心色"と呼びます)を取得し、それを周囲に設定しています。

3️⃣. これだけだと同じ領域中心色を何度も読み込むことになるので、領域中心色をHashMapでキャッシュさせてます

画像出力

保存ボタンが押下されたら、モザイク加工された画像を保存します。
まずダイアログを表示して、ユーザーにフォルダを選択させ、そのフォルダに入力ファイル名(もしくは一部加工したファイル名)で保存します。

 if ui.button("保存").clicked() {
    if let Some(path) = rfd::FileDialog::new().pick_folder() { // 1️⃣
        let mut saving_file_path = path.join(&image.raw_file_name);
        if fs::exists(saving_file_path.clone()).unwrap() {
            saving_file_path =
                path.join(format!("mosaic_{}", &image.raw_file_name));
        }
        let croped_image = image.saving_image.crop(
            self.trim_frame.left,
            self.trim_frame.top,
            image.saving_image.width()
                - (self.trim_frame.left + self.trim_frame.right),
            image.saving_image.height()
                - (self.trim_frame.top + self.trim_frame.bottom),
        );
        croped_image.save(saving_file_path).unwrap(); // 2️⃣
    }
}

1️⃣. ここでもrfdを使います。ただし、画像入力時とは違いファイルではなくフォルダを選択させるため.pick_folder()を利用しています。

2️⃣. ファイルとして保存する処理はDynamicImageに関数が生えていたので簡単に実装可能でした

ソースコード

https://github.com/diggymo/mosaic-editor-rs

感想

  • egui、サクッとツール作るには十分有用だなと思いました。
    • 地味に、Reactに似てるなと思いました。
      • だからといってReactエンジニアがいきなりチャレンジすると所有権がらみのエラーで大火傷を負うと思います
      • しかも、Reactと比べると状態管理がかなり手間…
  • 作ったものの、実装した時間をpayするほど使うことは多分ないと思います🥺
    • まぁ楽しいからいいんですけど
  • 他にもトリミング機能も盛り込んでいたんですが、解説がめんどくさく感じたのでとりあえずここまでにします
tech  Rust  egui 

See also