2023年12月9日から17日にかけて、公立はこだて未来大学で行われた、学内ハッカソン、p2hacksに参加しました。

成果物

今回もfuNGというチームで出ました。(メンバーは前回と少し違います)

世界にあふれる赤いもの(ぼくは「アンチひんやり」と呼んでいて、発表時もこの呼称を使いました)をカメラで撮って削り取る。どれだけ赤いものを削れるかで点数がつくというゲームです。こうして言葉にすると大して面白くないな…

レポジトリはこちらです。

p2hacks2023/pre-06 - GitHub

この記事はいわゆる振り返り記事的なやつです。

というか、チーム紹介の意気込みみたいなやつで

こんなことを書いたのですが、優秀賞(2位)を受賞したため、2位との$\delta$が0となり、目標未達成となりました。応援ありがとうございました(?)。

正直、今回は賞とか一切かすりもしないと思っていたので(実際企業賞はありませんでしたし)かなり嬉しいです。

アマギフ5000円分よりPaseri 5000円分の方が嬉しいみたいな話は置いておいて、下のほしいものリストからなんか買おうと思います。

発表会の話

「そういえばメンバー紹介も技術紹介もなにもなかったな〜」なんて思った人がいるんじゃないでしょうか?

このスライドは発表会のときに使ったスライドを一部変更したものです。このスライドの後半を見てもらえばわかるのですが、バッチリとサイトのQRコードが貼られていたり技術紹介があります。

つまり、割といい感じに紹介できていたように見えたかもしれませんが、ガッツリ時間切れだったわけですね。

僕の発表スタイルを知っているひとならわかるかもしれませんが、自分はスライド発表などの際、台本を持ちません。というか台本がありません。これは中学、高校のときからずっとそうで、スライド的な補助媒体だけは用意しておいて、喋ることはすべてその場で考えるというスタイルをずっと貫いています。未来大の推薦入試のときも、1年生のときのProcessing発表会のときもいつでもそうでした。今回も例にもれず、前に立って「じゃあ始めまーす」と言った瞬間からGPT-Youreinを用いて文章生成を開始、発表していました。

僕が昼飯を食べている間、スタジオにいた2, 3チームくらいがちゃんと発表準備していて偉いなーという印象でした。自分は昼食時間が終わって発表会までの、みんなが発表練習を行っていた時間にオンゲキという音楽ゲームのMizutama-TripsterのMASTER譜面の28小節目からを練習していました。階段→トリル→階段みたいな配置で、結構苦手です。ボルテの階段は螺旋階段も割と叩けるのですがオンゲキの階段は全く叩けなくてマジで何?って感じです。

曲がめちゃめちゃいいので、みんなにも聴いてほしいな〜〜〜〜〜

発表練習はちゃんとしましょう。自分は多分今後もしません。

開発の話

JugesukeくんとぺるきくんはずっとTypeScriptを書いていましたが、僕はRust wasmをずっと書いていました。そこらへんの話ができると嬉しい。
フロントエンド側の話はJugesukeくんかぺるきくんが書いてくれると思います。特に打ち合わせもしてないので知りませんが。

Rust wasmの担当部分は熱い画像の判定、熱い部分の切り抜きなどです。あれ、熱い部分の切り抜きなんてあった?と思われるかもしれませんが、以下のスクリーンショットの真ん中、削れ!と言われているパートで実際に削ることの出来る部分を決めるのに使っています。

実際にどういうアルゴリズムになっているかも説明しておきましょう。発表会のときに質問でちょっと聞かれたので「あっ!この隙に発表で話せなかった部分話しちゃえ!」と思って少し話しましたが、RGBではなくてHSVを用いています。

/// Classify a pixel is hot or not.
/// Details: https://github.com/p2hacks2023/pre-06/issues/7
pub(crate) fn is_pixel_hot(pixel: &Hsv) -> bool {
    let is_in_hue_range = pixel.get_hue() < 43.0 || 330.0 <= pixel.get_hue();
    let is_in_satu_range = 50.0 <= pixel.get_saturation();
    let is_in_value_range = 35.0 <= pixel.get_value();

    is_in_hue_range && is_in_satu_range && is_in_value_range
}

これは実際のコードなのですが、以下の条件をすべて満たすピクセルを熱いピクセルと呼んでいます。

  • $\text{Hue} \in [0, 43) \cup [330, 360]$
    • Hueは$\text{mod} 360$なので、この表現は適切ではありませんが、$\text{Hue} \in [330, 403)$ と言い換えることも出来ます。
  • $50 \le \text{Saturation}$
  • $35 \le \text{Value}$

SaturationやValueは100分率で表現されたり、0 to 255で表現されたりしますが、今回は100分率を小数点以下第2位で四捨五入したものを用いています。Hueに関しても同様です。

もともと、この評価関数はもう少し緩かったのですが、ゲームバランスの調整や、人間の直感を近似するために変更を加えました。 詳しくは、「熱さ」ってなに? · Issue #7 · p2hacks2023/pre-06Document: 熱さの基準を調整する · Issue #64 · p2hacks2023/pre-06を読んでみて見てください。

ところで、下の画像に映る水筒の奥で、椅子を2つ用いて横になりながら口をあけてスマホを見ているのは僕です。マジで何やってんだこいつ。

fn byte_to_image(bytes: Vec<u8>) -> Result<DynamicImage, Box<dyn Error + Send + Sync + 'static>> {
    Ok(ImageReader::new(Cursor::new(bytes)).with_guessed_format()?.decode()?)
}

/// Evaluate the "hotness" of the image.
/// For more details about the "Hotness", see https://github.com/p2hacks2023/pre-06/issues/7
#[wasm_bindgen]
pub fn evaluate_hotness(img: String) -> f64 {
    match byte_to_image(base64_to_img(img)) {
        Ok(image) => {
            let px_cnt = image.width() * image.height();
            let hotpx_cnt = image.pixels()
                                .map(|(_, _, px)| to_hsv(&px))
                                .filter(|x| is_pixel_hot(&x))
                                .collect::<Vec<_>>()
                                .len();

            (hotpx_cnt as f64) / (px_cnt as f64) * 100.0
        }
        Err(_) => {
            0.0
        }
    }
}

ちなみに、画像の熱さは熱いピクセルの全体に対する100分率です。赤の強さみたいなのを重視していると思っている人もいたっぽいですが、これに気付いた人はいたかな?

余談ですが、evaluate_hotnessにはテストコードがあって、テストケースは山岡家のラーメンを撮った画像と、水着衣装のサイレンススズカのGaze on Me!のスクショです。

RGBをHSVに直す

地味に苦戦したのがこれです。

当初Wikipediaに記載されていた式を使っていたのですが、インターネット上の変換ツールでの変換結果を用いて作成したUnit testが落ちまくる大変な事態になりました。流石に不思議に思い、テストケースを作成するのに使ったWebページの変換式を見てみると全然違う式が書いてあり、これ大丈夫?と思いながら別の出典を探してみることにしました。

どこか適当なウェブページを出典にしようと思いましたが、家に高校1年の時に買った「ディジタル画像処理」という本があることを思い出して、その本を出典とすることにしました。公益財団法人画像情報教育振興協会という割とちゃんとしたところが出している本なので、出典としては適していると思います。

WASMの話

Rust wasmはRustコードをwasmバイナリにコンパイルして、JSのインターフェースからwasmバイナリにコンパイルされたRustコードを呼び出す技術です。正確にはコンパイル時にwasm用の命令に置き換えられたりしているのでアレですが…

WASMなんもわからん…の人は今年のp2hacks運営メンバーのふねらるさんが書いた「異種Wasmランタイム間のライブマイグレーション」を読むとWASMの中身の概要がわかるかもしれません。なんかちょうどいいタイミングで記事が出てきたのでリンクしました。

今回このプロダクトでwasmを採用した理由はUse Cases - WebAssemblyに該当するような部分がいくつかあったからです。画像処理というタスクは画像ファイルの構造からループ処理が不可欠で、ソフトウェアのボトルネックとなりやすい部分です。その部分をRust + wasmという高速動作を望める言語で実装を行うことで、アプリケーションの高効率化を狙いました。まあ、JSで書くよりはスピードが出たんじゃないかと思います。最終的にぺるきくんのパフォーマンスチューニングで、熱いものを探している時にwasmに投げられてくる画像のサイズが$12 \times 21$くらいのめちゃめちゃ小さな画像になったんですが…

Rust wasmでの開発はそこそこ面白いことが多かったです。普通のRustコードならcargo runとかですぐに実際の動作を確認することができますが、wasmの場合、wasmバイナリをwasm-pack buildでビルドして、そのビルドコードをJavaScriptからコールする必要があります。これは大変な手間なので、基本的に自分はwasm-pack buildをしないという形で開発を行いました。どうするかと言うと、テストカバレッジをめちゃくちゃ高くします。幸いwasm-packにはテスト機能があって、ヘッドレスのブラウザでwasmコードをテストすることができたので、wasm環境で動くか心配なコードをwasm-pack testで走るテストに記述して、Rustのunit test、つまりcargo testで走るテストにはそれ以外のテストを記述しました。

例えばこんなテストがあります。

#[wasm_bindgen_test]
fn is_base64_works_correctly_question() {
    // Copied from https://docs.rs/base64/0.21.5/base64/

    let orig = b"data";
    let encoded: String = general_purpose::STANDARD_NO_PAD.encode(orig);
    assert_eq!("ZGF0YQ", encoded);
    assert_eq!(orig.as_slice(), &general_purpose::STANDARD_NO_PAD.decode(encoded).unwrap());
}

まあ名前からわかる通り、wasm環境で今回使用したbase64というライブラリが動くかを確かめたかったためのコードです。 ほかにも面白いテストがあって、

#[wasm_bindgen_test]
fn check_img_data_equivalent() {
    let original: String = format!{"data:image/png;base64,{}", DATA};
    let mid = base64_to_img(original.clone());
    assert_eq!(img_to_base64(mid.clone(), false), DATA);
    assert_eq!(img_to_base64(mid, true), original);
}

これはbase64エンコードされた画像をVec<u8>、すなわちバイト列に変換したあと、再度base64文字列に戻すというテストです。当たり前ですが、img_to_base64という関数とbase64_to_imgという関数は逆関数の関係にあり、DATA = img_to_base64(base64_to_img(DATA))が成立します。これを確認するテストです。このテストが重要な理由は、extract_hot_bufferという、画像の熱いピクセルを切り抜く関数がこの動作を行うからです。

/// Extract "hot" object from the input.
/// The output of this function is base64 encoded PNG image.
#[wasm_bindgen]
pub fn extract_hot_buffer(img: String) -> String {
    let res = match byte_to_image(base64_to_img(img.clone())) {
        Ok(image) => {
            let mut imgbuf = RgbaImage::new(image.width(), image.height());
            for (x, y, px) in image.pixels() {
                let hsvpx = to_hsv(&px);
                imgbuf.put_pixel(x, y, if is_pixel_hot(&hsvpx) { px } else { to_transparent(&px) });
            }

            let mut bytes: Vec<u8> = Vec::new();
            imgbuf.write_to(&mut Cursor::new(&mut bytes), ImageOutputFormat::Png).unwrap();
            img_to_base64(bytes, true)
        },
        Err(_) => {
            img
        }
    };

    res
}

それっぽく計算した結果なので正確ではないですが、カバレッジは70%程度くらいかなあと思います。行数ベースのテストカバレッジを計算するなら80~90%程度あるかもしれません。

コミュニケーションの話

メンバー、特にぺるきくんとはかなり喧嘩しました。

実は今回のプロダクトの原案は自分で、アピールシートのコンセプトなどを決めたのもすべて自分でした。
そのため、全体的には自分の提案を尊重してもらう形になっていたのですが、こだわりたい部分の違いなどがあり、度々衝突しました。

僕はソフトウェア開発、もとい、プロダクト開発はアイデアをより良いアイデアで殺す殺し合いで、意見をぶつけ合い議論、アイデアを研磨することで輪郭を形作っていくものだと思っているので、意見のぶつかり合いが起こること自体は問題と思っていないのですが、ちょっと自分が頑固すぎたかな〜という反省はあります。本番の発表は少し落ち着いた感じで行こうかと思ったのですが、16日(発表会前日)に「どうせならバカさ加減を貫いてほしい」的なことを言われたのもあって、「いつものLTをやろう」と思った節があります。

割とぺるきくんを同等の、もしくは自分より上位のプログラマとして見ている節があり―――自分が彼と同等と言うのは随分と自惚れが過ぎる気がしますが―――はっきりと議論をしてプロダクトを良い方向に押し進めたいという気持ちが強すぎたかもしれません。閉会式後に喋った感じだと割と自分に怒られていたと感じていたっぽくて、かなり反省しています。申し訳ありません。

個人的には他の部分ではコミュニケーションで困ったことはありませんでした。今回は割とIssueドリブンな開発をしていて、Issue上で会話したり、メンバーのtimesでボヤいたことに反応してそこからIssueを立てたり、IssueやPRのコメントだけを見ると業務か?というレベルでしっかりしていたと思います。その分Discordは雑で、深夜に音ゲーのリザルトを上げたり、開発中に横で流していたアニメの実況をしたりしていました。ダンボール戦記Wはやっぱり2023年に見ても神アニメだと思います。

すべてを終えて

はやくCHUNITHM LUMINOUSを触りたいです。ハッカソン期間中に音ゲーをしにいった時間はたいてい夜中の2時ぐらいだったので全然触れていませんでした。CHUNITHMもオンゲキと同じ様にメンテ時間を朝4時〜7時にしませんか?なんならコナミゲーみたいに24にしてくれもいいんですが。(個人的には連携サイトのランキングを更新したりする時間なのかなーと思っています。)

あとラウンドワン函館店は早く24営業に戻ってください