これは株式会社LabBase テックカレンダー 2022 3日目の記事です。2日目の、「バックエンドやりたい」って言ってるのに自分がバックエンドの仕事を奪ってしまうのでフロントエンドの仕事ばかりさせていてちょっと申し訳なく思ってる上久保さんの記事はこちら(最近バックエンドやれてるようなので少し安心しています)。
はじめましての人ははじめまして、株式会社LabBaseで「フロントエンドなんか触りたくないヤダヤダ」って言ってたら自分だけ組織図などで名前の後ろに(BE)って付くようになった岩井です。
タイトルが釣りっぽくなってしまったのでもう結論を書きます。最高効率でプログラミングを学習するには「何かを作ってみること」が最良です!!!
「うわー!そんなの100万回くらい聞いたよ、でも、作りたいものなんかないんだよ!」という声もありそうですが、やっぱりコレが最強なんだよなぁと思った次第です。
前半はやったことなどの振り返り、後半は作ったもの(OpenCVで取得したカメラ画像に日本語文字列を合成し、eguiのGUI上に表示するだけのことが結構面倒だった)の話になっています。
イントロ
最初に触ったプログラミング言語はHyperTalk、高専で最初に習った言語はPascal、就職してから15年以上Javaを使い続け、その他、JavaScript、C、Perl、PHPなども使えますが、最近はKotlinをメインで使いたいと思っています。
が、弊社のCTOがRust推しなのでRustを勉強しなくちゃいけなくなりまして、はじめましての状態からRustを使い始めてちょうど1年になります。
途中、Rustにあまり取り組んでいない期間があったので1年分の密度はないと思います。半年分くらいかも。
やったこと
インストール
新しい言語を使おうとする際、環境構築は案外手こずる部分だと思います。Rustの場合はrustup
さえインストールすればあとはなんとかなる印象です。
公式にも全部書いてあるので楽勝ですね。
JetBrainsにお金を払うことにしたので、IDEはCLionを使っています。Rustの開発にVSCodeを使う人も多いと思いますが、JVM言語を使っていてIntelliJ IDEAを使い慣れている場合は、ショートカットが同じであるCLionをおすすめできます。
VSCodeでRustをやる場合に必須ともいえるrust-analyzer
的な機能も一応使える(https://zenn.dev/ta14_u/scraps/42044ab20c78a4 参照)ので、機能面で困ることはないです。
プログラミング初学者の方へ
この辺り、完全にはじめての方だと何のための作業かわからない場合も多いかと思います。
基本的に「わからないことはわからないまま先に進む」ことが後でも必要になってくるので最初はよくわからないまま進んで良いと思います。経験者も、「いや、インストールとか最初にやったきりであんま覚えてないし…」ということも多いのであまり気にしなくても大丈夫です。
ここで大きく躓くと、「人に教えを乞わないと無理かも」と感じてしまうかもしれませんが、「プログラミングの適性があるかどうか」とはまた別のセンスが問われる部分だと思いますので、なんとかここは頑張って、もっと先で躓いてからお金のかかる学習方法を選択してほしいと個人的には思います。
基本的な構文の学習
個人的な感覚ですが、「変数の宣言」「ループ」「分岐」さえ書ければ「プログラミングができる」と言っても良いと思っています。なので、最初にこの辺の書き方を調べます。習得する言語が2つ目以降の場合、知っていることの書き方が多少変わるだけなので楽勝ですね。
基礎の習得については「Rust 入門」で検索して一番上に出てくるこちらを主な教本として読ませていただきました。
https://zenn.dev/mebiusbox/books/22d4c1ed9b0003
Rustの場合は「変数の所有権」というクセがあり、よく躓くポイントとされています。この時点では「そういうのもあるんだな」という理解でOKでしょう。所有権の移動を気にするのは関数呼び出しなどが登場してからで、mainだけで完結しているような場合は特に問題にならないはずです。
他にも沢山わからなかったことを後回しにしていますが、「使い始める」段階では不要と判断しています。
FizzBuzz問題
https://ja.wikipedia.org/wiki/Fizz_Buzz
一つの基準として、これがスムーズに実装できればプログラミングやれそうなスタートラインに立ったと言えるかなと思います。
逆に言うと、「どうしてもループや分岐のイメージができなくてFizzBuzzが実装できない」という方は、何十万も払うようなスクールに通ってそこで何かを得るのは難しく、費用対効果が悪いんじゃないかなと思います。
AtCoderで反復練習(1〜2ヶ月め)
書き方がわかったら書き方を身になじませる必要があります。
自分(AtCoder緑)の場合はAtCoder Beginner ContestのA〜Cくらいの問題をRustで埋めていくことで書き方に慣れていくことにしました。
やっている時はかなり「書けてるぞ!」と思ったのですが、前述の通り「mainだけで完結するプログラム」においては変数の所有権を気にしなくて済むので、"Rustが"身に付くという感覚ではありませんでした。
また、自分のレベルで楽勝な問題を別言語で考えてからRustに書き換えるかのような勉強法はあまり学習効果はなかったように思います。
やった感の演出もモチベーションの維持には必要ですけどね。
社内のRustマンのコードを見る(3〜6ヶ月め)
やってる間はかなり「わかる!」と思ったのですが、振り返ってみると主体的に学びを得ることはできてなかったと思います。「自分がやろうとしていること(業務で作るシステムの一部)」と「参考にするコードがやっていること(←と同じシステム)」があまりにも近い場合、コードの一部改変だけでやりたいことの大半ができてしまいます。コードの一部改変で大きな学びを得るのは難しい。ピンチでしか俺は成長しない。
この頃はずっと「メモリの解放についてちゃんと作られてる言語でBoxなどという方法で縛って良いのか?」とか「String::from()って一々書かなきゃダメなのか…?」とかで悩んでいました。
Stringについてはこの前なんとなく解決しました。
Boxも使わないとどうにもならないことがあったので、一応納得しています。
なんか作る(8〜10ヶ月め)
やっぱりコレなんですわ。
コレに対する「作りたいものなんかない!」という反発もよく目にします。でも、あらためて一からプログラミング言語を習得してみた結果、一番伸びたと感じるのがコレだったので改めて断言します。
前段で「コードの一部改変で大きな学びを得るのは難しい」と書きました。自分で考えてものを作る時には前例があったりなかったり、断片的には例があるけど全部を組み合わせたものはなかったりするので、「一部改変」では済まないことが多々あります。それを乗り越えるために書いたコードでこそ「何かを掴めた」という実感がありました。
また、いろいろやっているうちに最初に読んだテキストの理解できなかった部分が徐々にわかるようになってきます。基礎と応用の行ったり来たりが特に効果的だったと感じます。
以下、作ったものの話です。
作ったもの
端的に言うと「Vertual Webcam」でしかないです。
OpenCVで取得したカメラ映像に対して文字合成などを行うためのGUIをeguiで実装し、合成結果の画像をCoreMediaIOのプラグインに渡します。
ところが、最近のバージョンのZoomだと、VertualWebcamを入力として取れない(真っ黒にしかならない)っぽく、GoogleMeetなんかでしか使えない感じです。
また、プラグインへの受け渡しがまだあまりイケてなく、ファイル出力したものを読ませてる状態です。まず方法がダサいのと、60fps出すにはファイル出力は重すぎるのでここはいずれ直したいポイントです(30fpsなら大丈夫)。
CoreMediaIOのプラグイン部分はこちらを参考にさせていただいています(https://qiita.com/nariakiiwatani/items/b89c22fe710ee3d081dd )。
が、tcpで渡すようにできるところまで頑張れていないため、特定のパスにあるファイルを読み込んで出力バッファに移し替えることで、上記のダサい実装を実現しています。
(※12/23追記:他のアプリでカメラを使用していると、zoomはCMIOのプラグインを入力に取れなくなるという現象のようです。今回はファイルで受け渡しているので、アプリを起動していない状態であれば、CMIOのプラグインがファイルを読み込んで、それをzoomに渡すということはできました。)
(※01/05追記:上記の現象は、MacBook本体のカメラを他アプリで使用している場合に発生する現象のようです。USBで他のカメラを接続して、そのカメラを他アプリで使用する場合はプラグインをzoomの入力にできるようです)
作った理由
はっきり言って、Webミーティングであんまりカメラをオンにしたくないんですね。
カメラをオンにしてほしい側の言い分としては、「表情が見えた方が〜」的なことを仰いますが、「だったらカメラ入力から表情を解析して絵文字を顔面に乗せたらぁっ!」ってなもんです(そこまではできてない)。
そもそも、旧世紀からインターネットをやっていてその頃の習慣が染み付いている者としては、「みだりに容姿を公開しない」のが当たり前で、「それを強制されない権利」も認められるべきだと思っています(インターネット全体でへの公開ではなく社内の会議でそこまで言うことないだろという意見もごもっともだとは思います)。
あと、ミュート状態でも文字で発言できたら便利かなという気持ちもあります。
あと、「ガーン」って字幕出しながら色調反転したら一回くらいはウケそうな気がしませんか?その機能は真っ先に実装しました。
愚痴
遅刻した人が「遅れてすみません」って言いながら入ってくる時あるじゃないですか、それ、メインで話してる人の声に被せて全体に聞こえる声量で言ってることになってる自覚ありますか?って思ってます。そういうのも文字なら邪魔にならなくて良いかなと。
怨嗟
世の中にはいろいろとアバターを作れるサービスがあるじゃないですか、NintendoのMiiだったりMetaのHorizon Worldsだったり。MiiはともかくMetaがメタバースでやろうとしていることは現実の再構築に近い物だと感じています。現実の自己の容姿に似せたアバターにしなさいよと。だったら現実に存在するあらゆる容姿が再現できて然るべきだと思うんですが、左右で目の形が違う自分はどんなサービスにおいても自分の顔を再現することができないんですよ。流石にこの顔とは長年付き合ってきてるので、目のことをつっこまれても流せますが、「一般的なサービスで想定していない面構えなんだからやたらと見せたくない」って態度取る権利くらいあると思うんですよ。
ちなみにMiiはこんな感じです。ふざけないとやってられないので
ポイント
OpenCV
当初、MacでもWinでも動くようにしたかったので、カメラの取り扱いが楽なOpenCVに入力を担当してもらうことにしました。ただし、VertualWebcamとしては出力部分を共通化できないという問題があります。
カメラ画像に文字列を合成して表示するだけであればWinでもMacでも動くようにはできています。
GUI
最終的にはeguiに決めましたが、Tauriとicedも少し触ったので言及しておきます。
Tauri
「Rust GUI」で検索するとまず「Tauriが良いのかな」という印象を得ます。TauriはWebviewを使って表示するGUIフレームワークです。Webviewを使うということはHTMLとCSSとJavaScriptでなんでもできるということです(Rustのコードがなくてもいける)。レスポンシブなWebサイトをアプリとして移植しようとしたら既存のフロントエンドのコードがそのまま使えるんじゃないでしょうか?
ただ、WebviewとRustのプロセスの間でJSONを使ってデータのやり取りを行うらしく、画像データのやり取りを考えている今回は採用見送りとなりました(画像をbase64エンコードすればやりとり可能ですが、データが大きくなってしまうから嫌)。
iced
「保持モード」「Retained mode」で動作するGUIライブラリ。 参考:保持モードとイミディエイト モード
egui
「即時モード」「Immediate mode」で動作するGUIライブラリ。
たぶん「即時モードだと描画回数が多くなっちゃうよ」ということだと思うんですが、カメラ入出力を行う関係上、30〜60fpsで動作してもらう必要があるので即時モードのeguiで良いかなと判断しました。
都度update関数が呼ばれるので、その時の描画オブジェクトの状態を再設定し、それをライブラリに描画してもらう流れになっていて、「60fpsなら60fpsで毎秒60回updateが呼ばれるのかな?」と思ったのですがどうやら違うようで、MouseMoveなどの何かイベントが発生していればupdateが呼ばれるのですが、何もしていない間はupdateが呼ばれないようです。
60fpsで呼ばれる関数があるのなら、そこでカメラ入力を取得しようと考えていたのですが、結局よくわからなかったのでupdate内でタイマーを使って再度updateを呼ぶようにしました。
常に周期的にupdateを呼び続ける方法あるんでしょうか…?
また、最新のリリース(0.19.0)だと、テキスト入力でIMEが無効になるバグがある(未リリースの新しいコミットだと治っている)ようです。
組み合わせの問題
OpenCVでは画像をMatという形式で扱います。画像に文字を合成することもOpenCVの関数でできるのですが、フォントの指定ができないため日本語の文字列を合成できないという問題があります(Pythonでやっている例はありましたが)。
そのため、imageproc
crateのdraw_text_mut
という関数で合成することにしましたが、これには画像がimageprocのCanvas
である必要があります。
また、eguiで表示するためにはeguiのRetainedImage
である必要があります。
もちろん、Mat→Canvas→RetainedImageと変換していけば解決できる問題ではありますが、画像データの形式を変換すると画素数に比例して処理が重くなる可能性があるのであまり何度もやりたくはないところです(60fpsを目標とする場合、一連の処理が1/60秒未満で終わる必要がある)。
たとえば3*3ドットでアルファチャンネルがないこんな画像のMat形式では、こんな感じでメモリにデータが入っています。BGRの順番ですが左上のドットから右へ1ドットずつ1行ずつ区切りなしで並んでいる感じですね。
↓
このMatの画素情報を特に変換せずに保持する構造体を作りました。
struct BGRImage {
width: i32,
height: i32,
channels: i32,
buf: *mut [u8],
}
impl BGRImage {
fn from_mat(mat: &mut Mat) -> Self {
let arr: *mut u8 = unsafe { mat.ptr(0).unwrap() as *mut u8 };
let cols = mat.cols();
let rows = mat.rows();
let channels = mat.channels();
let buf: &mut [u8] = unsafe { core::slice::from_raw_parts_mut(arr, (cols * rows * channels) as usize) };
BGRImage { width: cols, height: rows, channels, buf, }
}
}
新たにメモリ領域を確保したりしていないことを確かめるため、mat.ptr(0)
とbuf
が指すアドレスが同一であることを確認しながらやっています。
(ここでは省きましたが、BGRImage
をeguiで表示してもらうための変換には新たにメモリ領域を確保しています。表示時に参照される時だけBGRをRGBに並べ替える方法が全く思いつかなかったので)
こいつにGenericImage
トレイトとGenericImageView
トレイトを実装してやれば、Canvas
に対して使えるdraw_text_mut
がMut(で確保した画素情報)に対して使えるようになるはずです。画像のサイズや画素に対する操作しかなく、「画像全体を取得」のような操作がないので簡単に実装できました。
(当初、Matに対してGenericImage
とGenericImageView
を実装しようとしていたのですが、あまりうまくいかなかったので結果的にこうなっています)
全部実装しなくてもいけそうだったので、一部todoのままです。
BGRImageをCanvasとして使うためのコード
こういうのをMat(Matをラップしただけの構造体)に実装したかったのですが、*mut [u8]
にしておかないとput_pixel
が無理っぽかったのでBGRImage
という構造体を作るに至りました。
impl GenericImageView for BGRImage {
type Pixel = Rgba<u8>;
fn dimensions(&self) -> (u32, u32) {
(self.width as u32 * self.channels as u32, self.height as u32 * self.channels as u32)
}
fn bounds(&self) -> (u32, u32, u32, u32) {
(0, 0, self.width as u32 * self.channels as u32, self.height as u32 * self.channels as u32)
}
fn get_pixel(&self, x: u32, y: u32) -> Self::Pixel {
let ix = (y as i32 * self.width * self.channels + x as i32 * self.channels) as usize;
unsafe {
match self.buf.as_ref() {
Some(p) => {
// BGR と RGB の違いがあるのでここで吸収する
let r = p[ix+2];
let g = p[ix+1];
let b = p[ix+0];
Rgba([r, g, b, 255])
}
None => {
Rgba([0, 0, 0, 255])
}
}
}
}
}
impl GenericImage for BGRImage {
fn get_pixel_mut(&mut self, x: u32, y: u32) -> &mut Self::Pixel {
todo!()
}
fn put_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
let ix = (y as i32 * self.width * self.channels + x as i32 * self.channels) as usize;
let rgba = pixel.to_rgba().0;
unsafe {
match self.buf.as_mut() {
Some(p) => {
// BGR と RGB の違いがあるのでここで吸収する
p[ix+0] = rgba[2];
p[ix+1] = rgba[1];
p[ix+2] = rgba[0];
}
None => {}
}
}
}
fn blend_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
todo!()
}
}
また、前半で少し触れた「Boxについて一応納得」したのは、カメラからの入力を取るあたりでした。Boxを使わずになんとかしようとしたのですが、どうしてもできず折れた形です(本当はBox使わなくてもできるのでは?」とまだ疑っています)。
カメラ入力をBGRImageにするコード
fn bgr_image_from_camera(cam_on: bool, cam_option_box: &mut Box<Option<videoio::VideoCapture>>, cam_width: i32, frame : &mut Mat) -> Result<BGRImage, Error> {
if cam_on && cam_option_box.is_some() {
if let Some(camera) = cam_option_box.as_mut() {
let mut tmp_frame= Mat::default();
camera.read(&mut tmp_frame)?;
let tmp_frame = tmp_frame.clone();
let w_scale = (cam_width as f32) / (tmp_frame.cols() as f32);
let h_size = (tmp_frame.rows() as f32) * w_scale;
opencv::imgproc::resize(&tmp_frame, frame, opencv::core::Size { width: cam_width, height: h_size as i32 }, 0.0, 0.0, opencv::imgproc::INTER_LINEAR).expect("TODO: panic message");
Ok(BGRImage::from_mat(frame))
} else {
Err(opencv::Error{ code: 0, message: String::from("no camera") }.into())
}
} else {
Err(opencv::Error{ code: 0, message: String::from("camera off") }.into())
}
}
おわりに
と、まあ、成功したものや最終的なものだけ羅列すると大した量を書いていないように見えると思いますが、ボツになったおためしの実装が山ほどあり、そういう"表に出ない部分"こそが自分を成長させてくれたなと感じます。
だから、「どうやればプログラミングが習得できるのか」と悩んだら、なんでもいいからコピペで作れないようなものにチャレンジしてみてほしいなーとあらためて思いました。
ということで明日は(色々コード見せてもらっておいて「学びが得られなかった」とかぬかしてすみません)Rustマンの@takahashik0422さんお願いします。