ラベル news の投稿を表示しています。 すべての投稿を表示
ラベル news の投稿を表示しています。 すべての投稿を表示

2026年3月20日金曜日

タブレット用の楽器アプリをたくさん作ってみた

タブレットで使いやすい楽器アプリを作りたいと思いました。 一般的にはピアノの鍵盤が最もよく使われますが、タブレット上ではあまり使い勝手がよくありません。 新たな楽器のあり方を色々と模索して、以下のようなアプリを作ってみました。
  • 4x4pad - 4x4 grid MPE MIDI controller
  • Celltone - Grid MPE MIDI controller with Janko-Piano layout
  • Isotone - Grid MPE MIDI controller optimized for chords
  • Hexatone - Hexagonal MPE MIDI controller with Wicki-Hayden layout
  • Glisstone - Hexagonal MPE MIDI controller optimized for glissando

タブレットとピアノの相性が悪い

まずはピアノがどうにも使い勝手が良くない原因を言語化するとともに、新しい楽器を作るのにどれくらい余地があるのか考えてみました。 色々と考えていて思ったのは、 (1) 手の構造をきちんと考えているか、 (2) 和音をどうするか、 (3) 子供から大人まで使えるか、 (4) 白鍵グリッサンド、黒鍵グリッサンド、白黒グリッサンドをどうするか、 あたりが重要ということでした。 そして大半のものは 1で躓くことに気付きました。 これは手の使い方に起因しています。 手は広げて使うならいいのですが、狭めた状態で複雑なことをするのは意外と難しいです。 タブレットのピアノだとどうしても狭めた状態になりがちです。 他にも大人は横方向に 20cm、縦方向に 15cm くらい広げて使うのが適切で、 指を体と垂直にしながら使わないと非常に苦しく縦の操作に弱いことがわかり、 そして親指と中指は 20cm ほど離れていることもわかります。 これらのことから、ピアノの鍵盤はある程度縦幅がないと押せないし、 横方向に動かすことを基本として考えないといけないことがわかります。 特に和音は指に横方向の動き以外が絡むと非常に再現が難しいことに気付きます。

タブレット上のピアノは特に親指の問題が大きいです。 親指を駆使しようとするとタブレットの大半の面積を必要としてしまいますし、 スマホはまったく使い物にならなくなります。 スマホやタブレットはせいぜい指3本、ほとんどのケースでは指2本しか考慮していません。 その理由は親指と小指を考慮するにはスマホだと画面が小さ過ぎるからです。 タブレットなら指4本がギリギリ使えます。 親指を駆使するピアノの UI はタブレットに根本的に合っていません。 仮に親指を使わないとしても、タブレット上でピアノをきちんと使うには 24鍵盤が必要で、 横幅を取ってしまうため鍵盤が小さくなり、和音を打鍵しづらい問題が発生します。 さらに、縦方向の小さなピアノで指4本で和音を実現しようとすると指の何本かで鍵盤を1つ飛ばすような形を作ることになるのですが、 これがめちゃくちゃ難しいことに気が付きます。4本指をただ横方向に広げることはできないのです。 さらにタブレットでは高低差を表現できないので黒鍵盤のグリッサンドはできません。 ここまで考察を進めると、タブレットにピアノは向かない気がしてきます。

指を曲げるときの動きも考えてみました。 4本指は横方向への動きに極めて弱いですが、縦方向なら強いことに気付きます。 そのため運指は縦方向への動きを絡めるとスムーズに動かせます。 ドレミ(ファ)の次は上か下方向にスライドして(ファ)ソラシとするとスムーズです。 さらに気付いたことは、そもそもたくさんの指を使おうとするから大変になるということです。 これはボタンの中間点を押したときに和音を出せれば解決できそうです。

同型鍵盤と平面充填

次にピアノの歴史を見ながら、より良い配列を考えてみました。 これは以下のページがとても参考になります。

流行れ!同型鍵盤 (Isomorphic Keyboard)

アコーディオン配列には3つ大きな欠点があって、 (1)まずは配置に柔軟性が出てしまう問題があります。 一般的な方式だけで3つもあります。一応 Cシステム が標準らしい。 (2) 打鍵のしやすさを考慮して 16 ボタンが 1セットになっており若干ボタンの数が増えます。 2列用意すると 32ボタン。かなり多いです。 (3) 横列が 4ボタンなので自動的に縦列が 8ボタンになります。 とんでもなくスペースを取ります。

改良するなら六角形ボタンのクロマティック方式だと和音を作りやすいです。 これは明らかに四角形より優れています。 ちなみに四角形や六角形だときれいな平面充填図形を作れますが、 他にも三角形、凸五角形、複数種類の形状を利用することが考えられます。 三角形は結構考えたのですが、思ったより気持ちの良い配列がありませんでした。 和音に弱くて実用的ではない感じです。 1パターンだけ良いものはあったのですが、若干もやもや感が残る配列でした。 あと三角形だと誤爆の可能性が高くなるのでそこもマイナスポイントです。

六角形にこだわらない複数種類の形状は以下のようなものがあります。 アリかも知れませんがハードウェアでもソフトウェアでも作るのが難しくなりそう。

個人的に最も良いと思ったは、多角形にこだわらない方法です。 わかりやすいのが T字ブロックです。
  □□□□□□□□
 ■■□□■■□□
■■■■■■■■
T字ブロックの良いところは、ほぼピアノと同じ形状で実現でき、 白グリッサンド・黒グリッサンド・白黒グリッサンドをすべて再現できる利点があります。 ただ横幅が長くなってしまうので、やはりタブレットでは限界を感じます。 これをドーナッツ型にするなども考えたのですが、ドーナッツ型の UI はかっこいいだけで手の構造と一致しないので打鍵感は微妙です。 タブレットだと複数段にするほうが明らかに演奏しやすいのですよね。 すべてのグリッサンドに対応して効率的な和音操作ができる形状はあるでしょうか。 今のところ浮かんでいません。

作ってみた

検討した成果を活かして、まずはコンパクトなグリッド配列の 4x4pad を作りました。 4x4 の MIDI コントローラーはすでに様々なものがあるので、 それを使いやすくしただけではあります。 このUI はほどほどに和音を打てるだけで、シレファなどの和音が押せない問題があります。

和音をきちんと押せて演奏らしい演奏ができるためには、最低24音が必要です。 とはいえ 4x4pad を縦方向に伸ばすと 縦に 8ボタン必要で、 白鍵盤と黒鍵盤の間が大きいので、これは微妙です。 私が一番良さそうと思った配列はヤンコピアノでした。普通のピアノとほぼ変わりません。 Wicki-Hayden Layout も割と良いのですが、黒鍵盤の位置に違和感が残ります。 黒鍵盤を左右に寄せるとどうしても白鍵盤との距離が遠くなってしまうので、 距離を短くしようと考えていたら、それヤンコピアノだよねと気付きました。 ただヤンコピアノはタブレット上では和音に弱い問題があります。

そこで色々考えているうちに、縦方向を一列増やすと面白いかもと思いました。 縦方向を一列増やすと音楽理論的に非常にきれいな配列ができます。 指の本数を考慮すると物理キーボードでは横に配列を伸ばしたほうがまず正解です。 ただタブレットは二本指で使うことが多いので、意外と打ちやすく、 また音楽理論的に綺麗なので打鍵に一貫性が生まれて覚えやすいです。 それぞれ Celltone (ヤンコピアノ配列)、Isotone (独自配列) として公開しました。

ヤンコピアノよりは Wicki-Hayden Layout のほうが和音は強いので、 Wicki-Hayden Layout も実装してみようと思いました。 最初は Wicki-Hayden Layout を左右に配置すれば打ちやすいと思ったのですが、 正六角形なので縦方向に使っていない空間がたくさんできてしまって微妙でした。 横方向もタブレットだとやや小さい問題があります。 だから Wicki-Hayden Layout は縦方向に伸ばしているし、 オルガンは斜めの配列が多いんだなと気付かされました。 Wicki-Hayden Layout は左右の手が重なるように演奏するので、ちょっと人を選ぶ感じがします。 より良い配列がないか考えた結果、一本指で滑らかなグリッサンドができる配列を思い付きました。 これはなかなかおすすめです。 Hexatone (Wicki-Hayden 配列)、Glisstone (独自配列) として公開しました。

実装に関しては、複数のボタンを同時に押せるように工夫しています。 普通はできないのですが、まず inset: -10% とすると 10% ぶん外側に判定が作れます。 これを利用すると透明なボタンの重なりを作ることができるので、 それを document.elementsFromPoint() でチェックします。 余白の判定にポインターが乗ったとき、重なり合ったボタンの両方にイベントを送信します。

ハードウェアを購入すると 10万円以上する MPE MIDI Controller がゼロ円で実現できました。遊んでみてね!

MPE (MIDI Polyphonic Expression) に対応した同型鍵盤型 MIDI コントローラー Hexatone、Glissatone を作った

MPE (MIDI Polyphonic Expression) に対応した同型鍵盤型 MIDI コントローラー HexatoneGlisstone を作りました。 演奏しやすい配列で 2つアプリを作りました。 また以前公開した楽器アプリにも改良を加えて、Aftertouch に対応しました。



CelltoneIsotone を作ったときには MPE のことをよくわからず作り始めましたが、 規格に準拠しようとして作ろうとすると、やはり MPE はちょっと仕様が足りないかもと思うようになりました。 MPE は CC#74 と Pitch Bend、Channel Pressure のみで構成されています。 ピアノのような楽器を演奏しようとすると、鍵盤を叩く音によって音の大きさが変わります。 このとき普通は CC#11 を使うと思いますが、MPE の仕様には CC#11 が含まれていません。 CC#74 で代替はできるんですが、楽器によっては CC##11 とはかなり音が違います。 Aftertouch もなかなか難しい仕様で、規格に準拠しようとすると音量は大きくすることにしか使えないんですね。小さくはできない。 以上のことから、MPE の正式仕様だけでは音量を小さくすることができません。 それはちょっとなーということで、規格範囲外の CC#11 で普段は操作するようにしています。 さらに CC#11 を使いたいときと、CC#74 を使いたいときで切り替えられるようにもしておきました。 もやもやが残りますが、非推奨だけど規格違反ではないし、動くから良いか。

配列に関しては、これまで作っていたグリッド型では和音にそれほど強くないことを感じていました。 一番強いのは六角形のクロマトーン型で、既存のものだと Wicki-Hayden Layout が一番洗練されています。 アコーディオンの配列はグリッサンドに弱すぎる上に、配列の種類が多すぎるのが問題点です。 ただ色々考えているうちに Wicki-Hayden Layout よりもっとグリッサンドに強い配列があることに気付いたので、実装してみました。 Celltone は Wicki-Hayden Layout の配列を応用しています。 Glisstone は一本指で 2音階ぶん綺麗にグリッサンドできる新配列です。 個人的にはなかなか良いと思いますが、いかがでしょうか。

2026年2月13日金曜日

MPE (MIDI Polyphonic Expression) に対応したグリッド型 MIDI コントローラー Celltone、Isotone を作った

MPE (MIDI Polyphonic Expression) に対応した グリッド型 MIDI コントローラー CelltoneIsotone を作りました。 演奏しやすい配列で 2つアプリを作りました。 ついでに以前作った 4x4pad も MPE に対応させておきました。



タブレットで手軽に遊べる楽器アプリの配列を考えているとき、 いまさらながら MPE (MIDI Polyphonic Expression) を知りました。 知らなかったんかい、というレベルの話ですが…。 MPE はピッチベンドや音量をノート単位で管理して、表現力を高めることができます。 MIDI 2.0 を使うと MPE がなくても同様の仕組みを実現でき、同時打鍵数の制約がなくなります。 ただファイルサイズが大きくなる問題や、現状では拡張子が不安定な問題などがあります。 MPE は MIDI 1.0 の延長線上で作れるので、現状では MPE のほうが安定しそうです。 ちなみに MPE の実装を MIDI 2.0 に拡張するのは簡単そうです。ID をイベントに付与するだけかな。 Midy では MPE をサポートしていないかったので、実装してみました。

ただ MPE って音量の標準的な扱いがいまいちわからないんですよね。仕様的には Channel Pressure を想定しているのかな。 でも Channel Pressure は色々なエフェクトを盛り合わせにできるから、エフェクトを追加したい時にむしろ困る気がするんだよなあ。 私は CC11 で音量を調整しています。規約違反というほどではないし、いつでも実装は変えられるから、まあ良いかなと。 (本当はリリース後に直すのを忘れていたことに気付いたんですがね…。 いまのバージョンでも実害は何もなく、また Channel Pressure のほうに小ミスがあるように感じたので、次リリースでは直すかも知れません)。 Midy もだいぶ成長して、新しい仕様にサクッと追随できるくらいになってきました。

MPE を考慮した配列も同時に再検討しました。 ボタンを押す位置がすごく重要になるので、演奏は割と難しい気がします。 まずは指の位置でボリュームを変えられると良いのはすぐわかります。 また隣の鍵盤に触れたとき次の音を出せると嬉しいでしょう。 前は noteOn で処理していましたが、表現力を高くするには鍵盤の概念をもっと緩くして、 隣り合う鍵盤に近づいたときはピッチベンドで変化させると良いのでしょう。 このピッチベンドを主体に考えると、グリッサンドに強い配列が良いことになります。 ピアノ型の UI は縦方向の動きを音量、横方向をピッチベンドと考えると 操作しやすいかと思ったのですが、実際の製品を見るとすべてピッチベンドで実装してそう。 一番欲しいのは鍵盤ごとの音量調整かと思っていたけど、そうでもないのかな。 クロマトーン型の UI だとボタンごとの境界で距離に応じたピッチベンド、 ファーストタッチの位置で音量設定ができます。 縦方向に移動するときは横方向を音量に、横方向に移動するときは縦方向を音量にします。 アフタータッチで音量を変えにくいのは若干課題ですが、 タッチ位置を変えつつ連続タッチすれば一応は大丈夫そうかな。

配列に関しては、既存のものだと Janko-Piano がグリッサンドにまあまあ強くて良さそうです。 Celltone は Janko-Piano の配列を応用しています。これは普通のピアノとほとんど一緒なので演奏しやすいです。 Isotone は Janko-Piano が 2x6 なのに対して、3x4 に変更したらどうなるのだろうかと試行錯誤して作った配列です。 音程変化ボタンが増えすぎてしまうのが難点ですが…。ペダル用のボタンにしたりしても良いのかも知れませんね。 Isotone はタブレットのように 2本指で扱うことをメインに考えると、割と演奏しやすいです。 和音が非常に押しやすい配列になっており、指の数を増やしにくいタブレットではかなり有力だと思っています。 他にも様々な UI を既に考えてあるので今後作っていきます。

UI に関してはボタンとボタンの間に空間を用意しました。 空間部に触れながら指をスライドさせたときピッチベンドと音量変更が同時に変更できます。 まずはこの空間には gap を用意することでボタンとの違いを明示するようにしました。 gap がある程度大きくないとピッチベンドが効きすぎて音量だけ変更したいニーズに対応できないので、 一般的な MIDI コントローラーよりすこし空間を大きく設定しています。 ちなみにすべての場所でピッチベンドと音量変更が動いてしまうと、 音が変化しすぎて不便なだけなので、ボタンの中をスライドしても変化しません。 UI としてはこれ以上簡単にするのは難しいと思う。 感圧式タッチパネルなら音量を設定しやすいけど、ハードが高いので専用機器以外で主流になることはもうないでしょう。

ROLI Seaboard の初期発売は 2015年で、後続製品は今も MPE デバイスで一番面白そうに見えます。 現行製品と対等のアプリを作れるようになってきて、ようやく少し追いつけてきた感じ。 タブレットで実現できたことで、10万円近く掛かるデバイスを無料で遊べるようになりました。

2026年1月13日火曜日

手軽に遊べる楽器アプリ 4x4pad を作った

小中学生が遊べる楽器アプリを作りたいと思いました。 MIDI を使えば 200種類以上の楽器に強弱を付けて再生ができます。 タブレットでピアノの形状を模倣しても使いにくいので、もっとシンプルなものが必要です。 そこで実験的に作ってみたのが 4x4pad です。 4x4pad を使うと、子供向けの楽曲なら私でも簡単に演奏することができました。 なかなか良い。



作ってみて思ったのですが、昔は iOS に 3D Touch があったので pressure が得られたのですが、 今は廃止され pressure は常に 0 を返却します。Android も筆圧センサー搭載モデルは少ない。 同じようなことをするには width/height から pressure を推定するくらいしかないですが、 これは不安定で事実上使い物になりません。 最近は pressure を使ったアプリを作っていなかったので、今さら気付きました。 3D Touch を考慮した実装は入れてありますが、使えるデバイスは少ないのが不満です。 タブレットの楽器アプリは色々限界あるなあと思いました。 まあ昔から音ゲーを作るなどの用途でも様々な障害があってタブレットには苦労してきましたが…。

そんな訳で音の強弱は設定の Expression くらいでしか付けられませんが、 色々な音を出して合奏するには適したアプリになっていると思います。 ただボタンを配置するだけではなく、1つの指で複数のボタンを押せるようにしてあるので、和音が作りやすいですし、指を動かすだけでスムーズに音が鳴ります。 今回は MIDI コントローラーでよく使われる 4x4 グリッドを利用して作ってみましたが、 本当はもっと色々な配列を考えています。 いくつか楽器アプリを作ってみるつもりなので、そのうち配列などについての考察記事を投稿します。

4x4 グリッドの MIDI コントローラーは山ほどありますが、 どれも設定が必要で面倒臭すぎるので、設定なしで使える UI にしているのも、他と少し違うところです。 といっても MIDI コントローラーで遊んだことはなくて説明書などを見ながら言っているので、間違ってたらすまん。 選択しているドラムをグリッドに表示したりするともっとユーザーフレンドリーかなあ。

2025年12月14日日曜日

Web 上で使える Timidity++ っぽい MIDI プレイヤーを作った

Timidity++ っぽい MIDI プレイヤーを作りました。 前回に引き続き、Midy の機能確認やバグ潰しが目的で作ったアプリですが、 割と人気の UI ではあるので使い道もあるかも知れません。 本当は紹介のためにスクリーンレコーダーで録画してみたのですが、 Web Audio API がうまく録音できなかったです。音なしだと面白くないので動画は特になし。



私も割と好きな UI ですが Velocity は鍵盤の色で表現するほうが良いと思っていたので、 64〜191 の間で色を調整して表示しました。これだけでだいぶコンパクトにできます。 それ以外は Timidity++ とだいたい同じですが、簡易的なミキサーアプリとしても使えるようにしています。 スライダーがありきたり過ぎるので改良しようかとも思ったのですが…、 面倒すぎてデフォになりました。スライダーは弄ろうとするとなかなか大変です。

実装では鍵盤をどう光らせるかで多少悩みました。 Web Audio API でやろうとすると、ended event はありますが、started event がないので、意外と難しいです。 AudioBufferSource.start() と同じタイミングで動くようにタイマーを設定すると、 タイマーの数が 2倍になってしまって速度の問題が出てきてしまいます。 Web Audio API を使ったタイマーだと負荷が大きくなってしまうので、 requestAnimationFrame() で描画タイミングと同期しながら、 noteOn などの時間を監視して光らせるのがおそらく一番効率が良いです。 鍵盤を光らせるには Midy 内部で処理するイベントのタイムラインを見ながら処理します。

Midy の再生開始判定と、@marmooo/midi-player の判定は微妙に違っています。 @marmooo/midi-player は再生ボタンを押したときにサウンドフォントの読み込みもします。 このへんの処理をどうすれば楽にできるかを考えていて少しだけハマったので、 再生ボタンを押したときから isPlaying = true な変数を持つように実装を加えたところ、 割と簡単に GUI 拡張を作れるようになりました。 GUI 側でも isPlaying の状態を持つことで、再生の終了判定も容易になりました。 最近の Chrome は AudioContext をつけっぱなしにすると CPU をかなり消費するようになったので、こまめに AudioContext を ON/OFF しています。 Chrome の AudioContext の CPU 負荷はバグとしか思えないので、 そのうち直っているかも知れませんが。

最後に鍵盤を鳴らす実装をしていると、グリッサンドに耐えられない問題が発生しました。 事前にすべてをキャッシュするのも手と思ったのですが、 keyRange/velRange と 2つパラメーターがあるのでちょっとメモリが心配です。 そこで await で音声波形をデコードする前にスケジューリング配列に追加してしまうことで、 問題なく再生できるとわかりました。 ただこれだけだと処理時間が少ないことで運良く実行できているだけだと思うので、 スケジューリング配列に登録するとき note に pending 状態を付けて、 pending 中に noteOff が発生したときは、noteOn の処理内で noteOff も実行するようにしました。 これならリアルタイムな割り込みが発生しても問題なく処理できるはずです。

ここまでの実装で Timidy は動くようになったので公開としましたが、色々と余地はあります。 例えば midy 0.4.0 の noteOn は、SF2 modulation に渡す状態配列や noteOff と整合性を保つために await を付けて処理していますが、波形のデコードが 〜50ms で、 状態配列 (256 x 32bit = 1KB) のコピーが 〜0.02ms であることから、 noteOn から noteOff までの間に状態配列と SF2 modulation に変化がなければ、 await せず状態をコピーしながら処理でき、Worker で並列処理で高速化可能のはずです。 面倒そうだけど並列処理すればグリッサンドに対してより頑強になるはずです。 これもそのうち検証しようとは思いますが、まずはイベント数を減らすほうが確実とは思います。

2025年11月25日火曜日

GM2 MIDIミキサー Humidy を作った

MIDI Player/Synthesizer ライブラリ Midy の実装がだいぶ進んできて、 GM2 の再生も安定してできるようになってきました。 まだ細かい実装不足やバグ、再生負荷の課題はあると思うのですが、そろそろ実用段階に入ってきています。 そこでバグ潰しも兼ねて、GM2 の機能をすべて使える MIDI Mixer アプリ Humidy を作ってみました。



GM2 は仕様がかなり大きいので、機能をすべて使ったアプリとか見たことないんですが、実際のところどうなんでしょう。 ちなみにすべての機能を本気で使おうとすると UI が複雑になり過ぎるので省略している箇所はありますが、ライブラリからはもちろんすべて使えます。 なるべくシンプルに作ったつもりですが、複雑な機能は簡単な UI にはしにくいので、 普段は表示しないように押し込み、設定項目も無理やり減らしています。

名前は Timidity++ のもとになった英単語で timidity があって、 同じように midi が出現するもう一つの英単語として humidity があるので、 humidity と Midy をもじって Humidy としました。

Midy は自分に合わせて作っているので作りやすいのは当たり前とはいえ、 かなり短いコードで音楽ミキサーアプリを作ることができました。なかなかいい感じ。 動的サウンドフォント、動的 fetch プログラムチェンジ、GM2 なども動くようになったので、いよいよライブラリとしても完成度が高まってきました。 もう少し高速化したらすべての MIDI ライブラリは Midy に移行できそう。 速度も激ヤバ MIDI 以外なら問題になることはないので、もう実用レベルと思います。 激ヤバ MIDI は瞬間的なイベント数の負荷がブラウザの限界に達して死ぬので、イベント数を減らす処理をこれから入れていきます。

MIDI はサウンドフォント周りをもっと設定できたら面白そうなので、 もう少し色々改良したいなと思っています。個人的に気になるのはリバーブです。 MIDI はサウンドフォントで Reverb Effects Send を決め打ちしますが、 そこで決め打ちすると楽器によってはリバーブ効果がわからなくなります。 現実のリバーブは Reverb Effects Send のように楽器によって効果が決まるのではなく、 周波数に応じてリバーブの効果が変わる (低音ほど消える) はずなので、 音響理論で考えると微妙な気もして、設定できたほうが嬉しい機会もあると思いました。 開発者以外でこれに悩む人はいるのかという疑問はありますが、 MIDI プレイヤーをゼロから実装していると、こういう細かいところが気になってきます。

バグ潰しのために、さらに何種類かアプリを作ってみる予定です。

2025年10月2日木曜日

中学理科一問一答・中学社会一問一答を作った

中学理科一問一答と中学社会一問一答を作りました。 きちんと教科書を使って作っているので、教科書準拠の重要語句を手軽にチェックできます。 苦手分析もしっかりできるので、学校・塾・家庭でも使いやすいんじゃないかな。 中学理科は計算問題を含めていないので語句・公式の確認に使えるくらいですが、それだけでも多くの人に使い道があると思います。 四択問題で簡単なので、正答率は8割以上が目安なんじゃないかな。 間違った問題は復習が必要です。



重要語句の意味や事実は静的なので、AI 生成することで作ってみました。 穴埋め問題は AI が最も得意とする分野なので、精度も非常に高いことが期待できます。 とはいえ融通が効かず 10分前に話した内容をすぐ忘れる AI さんに作ってもらうのは大変でした。 それでも人間が作るより 100倍くらいはコストが低いと思いますし、 100倍楽なのだから頑張ろうと思って作りました。 いかに安定して生成させるか、いかに質を安定させるか、 いかに選択問題として使えるようにするか、いかにメンテできるようにするかが重要になります。 AI さんの知識量は凄いですが、行動は物忘れが激しいため細かい指定をしても守ってくれません。 テストをきちんと書くことが大切だなと思いました。 結構色々なチェックをしているので、問題がクソなケース以外はかなり除外できていると思います。 問題がクソかどうかはたくさんプレイしてみてわかることなので完璧は難しいですが、 ある程度は自分でもプレイしてみて確認はしています。 このへんの開発の手間具合は、1年経ったらまた状況は違うんでしょうが、現状は AI があってもやはり大変です。

アプリとして公開できるようにするためには、かなりチェックが必要でしたが、 それでも一度作ってしまえば、メンテコストは低いし、生成コストも、チェックのコストも低いのが良いところです。 違う問題を作ってと言えば、他の問題も作れます。 人間にこれをやらせると、個人開発では絶対無理だし、集団でもとんでもなく時間が掛かります。 人間や過去問をベースとして作るよりAI で作ったほうが、一般性も網羅性も高くなる利点はあるので、結構良い AI の利用例かなと思います。 ただ社会はかなり安定して問題を作れる一方で、 理科は AI さんも知識不足な気がするので、問題はだいぶ自作しました。 社会は割と安定して出力できるものの、やはり致命的なミスがたまにあったりするので、日頃の確認が必要です。

近年は高校受験や大学受験の問題を見ても、一問一答そのものが問題として出てくる機会はなくなっていますが、 知識確認には依然として有用です。 問題を解いた後に、どれくらい他のことを言えるか考えてみたり、使い道はたくさんあります。 人気の問題集とかを見ても、結局は一問一答の延長線である問題のほうが多いので、 実質的にはみんな今も使っている状態と思います。

そういえば AI で作れるよなー、くらいの気持ちで作ってみましたが、なかなか良いんじゃないかと思います。 他のアプリもまったく確認せず作り始めたのですが、いくつかはあるみたいですね。 まあ AI 生成ドリルとか、塾用のとか色々あるもんな。 でもなんというかすぐに使えるものはなかなかないし、実際欲しいのはすぐ使えるものなので、まあ良いかなという気持ちです。 今のところ中学社会一問一答・中学理科一問一答という名前にしていますが、 今後の実装レベルによっては名前を変えるかも知れません。

2025年7月6日日曜日

圧倒的インフレゲームの億千万タイピングを作った

圧倒的インフレゲームの億千万タイピングを作りました。 タイピングは色々なものを作っていますが、タイピングを最も学ぶのは小3〜小4なので、 その年代でより面白いものが作れればと前々から思っていました。 という訳で作ったのがこれで、学習指導要領に合わせて万・億・兆などの桁を学びながらタイピングができます。 さらに京・垓・秭・穣・・・無量大数まで対応しているので、圧倒的インフレのタイピングを楽しめます。



タイピングとしてはすこし難しいけど、面白いかもなという感じです。 私は小さな頃、家に転がっていた参考書を読んでいたら無量大数まで桁があることを知ったのですが、 今の子はどうやって覚えるんでしょう。兆の先を知る機会ってあんまりないんじゃないかな。 こんな感じの神ゲー(笑) で遊んでみるとすぐに覚えられます。

いわゆるインフレゲーはたくさんありますが、無量大数がすぐに出てくるゲームはなかったと思う。 ゲームの終盤になってやっと出てくるのが限度でしょう。学習用途で使うのはなかなか難しい。 その点、億千万タイピングはスタート時点から無量大数に親しめるので、効率的に勉強できます。

億の桁を超えると、ローマ字を表示しきれなくなるのでどうするかで悩みましたが、 表示しきれないものは見えないようにしてしまって、 桁が変わるごとにテキストを削減することで対応しました。 何回か遊べば、その後は違和感なくプレイできると思います。

神ゲーというかネタゲーですが、たまにはこういうのも良いと思う。 無量大数の得点をゲットできるので、友達と争うのにいいかも。 打鍵速度が十分に早くなってくると引き運ゲー感があるけど、そこはまあ仕方ない。

2025年6月15日日曜日

シームレスなエフェクトを適用するアプリ CV-Masker を作った

シームレスなエフェクトを適用するアプリ CV-Masker を作りました。 マスクを手書きで設定して、そのマスクに対してグラデーションをかけつつシームレスなエフェクトを適用できます。



色々なエフェクトを用意しているので画像はあくまでイメージですが、こんな感じの画像をサクッと作れます。



最初は cv.colorChange, cv.illuminationChange, textureFlattening などの シームレスなエフェクトを与える関数の確認のために作っていたのですが、 いくつかボツ案があったので、マスクにエフェクトを掛けて遊ぶアプリに変えました。

当初の目的のシームレスなエフェクトの関数は、良い感じの画像を作れるのですが、 へぼい CPU だと処理速度がちょっと遅かったです。 コードを見ると SIMD + Threads の最適化は甘そうなので、将来の改善に期待です。 やはり OpenCV と言えども、コア部分以外は遅い時もあるとわかりました。 あと textureFlattening は実行するたびに結果が異なるので元の実装がバグってると思う。

OpenCV の cv.colorChange などの関数は高度なシームレス処理を行っていますが、 たいていそこまで精度は必要ありません。 そこで自作の軽量な汎用局所シームレス関数を作って遊べるアプリにしました。 0/255 で書いたマスクを boxFilter でグラデーション化して、 グラデーション化したマスクに任意のエフェクトを掛け合わせます。 処理は非常に軽いですが、十分なシームレス感があります。 どんなエフェクトでもシームレスに適用できますが、 一般的かつシンプルなエフェクトをいくつか利用できるようにしておきました。 局所モザイク、局所シャープ化、局所色調補正などが利用できます。 いざ作り始めたら追加したいエフェクトが多すぎて困ってきましたが、 応用的なエフェクトは別のアプリで作ります。 アルゴリズム多すぎのものはすぐに組み込むのが難しいです。

シームレスなエフェクトを加える関数は、メモリリークが直らず苦労しました。 やはり C++ は行数が増えてくると、ハマったときになかなか厳しい。 結局少し前のバージョンでは直らず、すべて書き直したら直りました。 メモリリークそのものは input event で大量に処理させるとすぐに見つかります。 たぶん MatVector の扱いが一番難しいのですが、 push_back() はコピーらしいので、すぐに delete() するのが綺麗だと思う。 参照がどうなってるか熟知してないとできないのが厳しい。
const resultVec = new cv.MatVector();
for (let i = 0; i < 4; i++) {
  const ch = srcChannels.get(i);
  resultVec.push_back(ch);
  ch.delete();
}
手軽にいい感じのエフェクトが作れるので、結構使いやすい気がします。

2025年5月12日月曜日

様々な非写実的レンダリングを適用するアプリ CV-NPR を作った

様々な非写実的レンダリング (Non-Photorealistic Rendering) を実現するアプリ CV-NPR を作りました。 最近は生成 AI で画像がすごく簡単に作れるようになっているので、そちらとは技術がズレている感はありますが、 最近 Web 上で手軽に使えるようになった技術を地道にアプリ化しています。



色々なエフェクトを用意しているので画像はあくまでイメージですが、こんな感じの画像をサクッと作れます。



このアプリでは OpenCV の cv.detailEnhance, cv.edgePreservingFilter, cv.pencilSketch, cv.stylization、cv.oilPainting などの面白エフェクトが利用できます。 上記はそれなりに有名でいろいろな記事がネットでも見つかりますが、もっと色々なエフェクトが欲しかったので、 モザイク、色鉛筆化、cv.applyColorMap, cv.anisotropicDiffusion などをさらにサポートしました。 色鉛筆化は Lineart Converter を作ったときにできた副産物です。 cv::anisotropicDiffusion はうまく使うと迷路画像や、味のあるスムージングができます。 たいして設定項目がなく使えるエフェクトはこれくらいでした。 他に何かあるかな?

GIMP や Photoshop で使うようなエフェクトが Web 上で実現できれば、割と便利かもなと個人的には思っています。 とはいえまだまだエフェクトが足りないのが現状です。 ただあまり時間は掛けたくないし、今回は OpenCV だけを使って実現できることを実装しています。 OpenCV を使えば 1日で根幹部分は作れるので…。 なんらか実装が必要なものは、他のアプリで作る予定です。

実装はしないように心掛けたアプリなので、正直このアプリでは開発期間の大半はビルド時間だったりします。 公式にはまだサポートされていないように見える機能を色々使っているので、チェックするたびにビルド時間が掛かりました。 色々なモジュールを触り始めたことによってビルドが苦痛でした。 wasm を作るだけで 1時間近く掛かります。 またアプリごと・ビルド種類にビルド用のディレクトリを持ってキャッシュすると 1GB 以上容量がいるし、 キャッシュしてもオプションを少し変えただけで無意味化する問題などがあります。 ESM 並の tree shaking ができるなら依存関係をモリモリにしてビルドできるのですが、 依存関係をモリモリにすると不要な定数を大量に登録される問題もあります。 このままアプリを増やしていくとビルドだけで 1日掛かりそうな気がしたので、 依存関係を真面目に処理して簡単にビルドできるスクリプトを作りました。 ビルド時間が 1/100 になるので本家にも反映してほしい機能ですが、 デフォルトの Python 設定ファイルだとモジュールの情報がなくてできません。 OpenCV には裏コマンドとして JSON 形式の設定ファイルがあるのですが、そちらならできます。 というか Python 設定ファイル、JavaScript で言うところの eval 使っていて、 危ないしやめたほうが良い気がするけどなあ。 本体の改良もできるようになってきて、OpenCV のこともちょっとわかってきた気がします。

2025年4月23日水曜日

画像の部分修正アプリ Inpainter を作った

画像の部分修正アプリ Inpainter を作りました。 名前の通り Inpaint アルゴリズムを使っています (安直)。 OpenCV と opencv.js の勉強、AI を使わないアルゴリズムの性能確認のために作りました。



不要オブジェクトを削除したこんな画像がサクッと作れます。 注意点としては、分布を調整するだけのアルゴリズムなので、消しゴムマジックのようには使えません。 消しゴムマジックのアプリだと思って使うと、ただの雑コラになるでしょう。



画像にちょっとしたノイズが走っているときに、それをいい感じに消してくれるアプリと思えば、なかなかの精度です。 ただアルゴリズム的には周囲の分布を見ながら消すので、周囲の分布が安定していないとうまく行かない訳です。 周囲の分布をどれくらい考慮するかは radius パラメータで調整できる訳ですが、 普通に考えれば周囲の分布が安定しているかどうかのほうがよほど重要だとわかります。

とはいえ分布ガチャをうまく引けば良い訳ですから、消したい箇所を何度か指定しながら分布を安定化させると、割といい感じの画像になります。 機械学習を用いた場合は分布を既存の知識を使って綺麗にごまかすイメージですが、inpaint アルゴリズムはガチャで分布を綺麗にしてごまかすものだと開き直ると、割と使いやすい気はします。

inpaint のアルゴリズムは cv::photo と cv::xphoto に実装されていますが、 cv::xphoto の inpaint はまだ Wasm ビルドができない感じです。 ちょろっと定義を変えれば動く気はするんですが、こういうときなかなかツライ。 動くようになったら追加予定です。

2025年3月19日水曜日

画像の背景を削除する GrabCutter を作った

画像の背景を削除する GrabCutter を作りました。 名前の通り GrabCut アルゴリズムを使っています (安直)。 前景と後景をアノテーションできるようにしておいたので、 削除と復元の微調整しやすいのが利点です。



こんな感じの背景透過画像がサクッと作れます。



割と大きめの画像でも 初回実行は 1秒以内、微調整は一瞬という感じです。 巨大な画像に適用すると abort するのが課題そうです。 ROI を作って部分適用したほうが良いかも知れません。 他にも共有メモリで分散処理はどんなアルゴリズムでも検討したいところですが、今回はそこまで作ってないです。 AI を使わない時にどれくらい精度が出るのかの勉強用で作りましたが、 AI なしでも割と良い精度です。せいぜい 2-3回の微調整で十分な結果が得られるので、 これはこれでアリじゃないかなあ。

グラフカットの試行回数がパラメータで設定できるのですが、まったく必要ない気がします。 試行回数が増えても遅くなるだけなので、1回で固定して微調整で修正したほうが良いです。 alphamat をさらに考慮する方法などもあるっぽいことに後から気付きましたが、 ものすごい時間が掛かるみたいなので、現状はこれでいいかなと。

2024年12月8日日曜日

動画や画像の同時視聴アプリ「ぺたぺた」を作った

動画や画像を画面内にぺたぺた貼ることで、たくさんのデータを同時に視聴するためのアプリを作りました。 画面全体に等間隔で並べたり、サムネイル形式で表示したり、自由配置で表示したり、様々な表示形式に対応しています。

ときどき動画や画像ファイルを一度にたくさん開いて同時に見るのですが、良いツールがありません。 一般的な動画プレイヤーで開くとプロセスが大量に立ち上がって管理しにくいです。 一般的な画像ビューワーで開くと画像を一覧にして見にくかったりする時があります。 そんな訳で作りました。



たとえばこんな感じの UI で動画や画像を見れます。



最近はほとんどのツールを Web アプリ化しているので、動画プレイヤーや画像ビューワーの Web アプリ化はちょうど良い機会になりました。 作ってみて気付いたのは、Web アプリのほうが動画や画像のロードが早いことです。 プロセスをたくさん立ち上げないで良いし、Web ブラウザの実装が高速だからと思います。

割とサクッと作れましたが、なかなかいい感じです。 D & D での貼り付け、Ctrl + V での貼り付けに対応しており、スムーズに動画や画像を追加できます。 動画サイトの同時視聴などもできると嬉しい人がいるかなと思って、 HTML タグの貼り付けにも対応しておきました。 HTML タグが貼り付けられるので SVG や数式、メモの貼り付けなどもできます。 ホワイトボードアプリや付箋アプリとしても使えるかも知れません。 ちなみに YouTube だったら共有用の embed タグをコピーして貼り付ければ良いだけです。 ただ YouTube などの動画サイトはウィンドウサイズによって色々挙動を変えているので、 コツを掴まないとリサイズ処理がスムーズにいかないのが欠点です。たぶんどうしようもない。

Sortable、Resizable、Draggable などのクラスをたくさん実装したのですが、 なんかもうこのへんの機能はライブラリを使わないほうが良い気がしています。 ライブラリを使うと古くてあまり効率的な実装になっていないことが多く、 古い実装に影響されすぎて逆につらい気がするのが昨今です。

2024年11月1日金曜日

画像を SVG に変換する image2svg を作った

以前作った @marmooo/imagetracer のフロントエンドアプリとして image2svg を作りました。 image2svg の名前で利用したかったので分離しただけです。



たとえばこんな感じの変換ができます。



生成されるまでどうせ待たないといけないので Worker を使わず実装していますが、使ったほうが良いか微妙なところです。 だいたいのデータで 1秒以内に結果が得られます。 1秒以内に実行できれば他のアプリよりはかなり早いのですが、気持ちよく利用するにはまだまだ高速化が必要です。 とはいえシンプルな高速化はだいたいやりきったので、今後は Wasm 化やマルチスレッド処理を考えたほうが良さそうです。

デフォルトで使いやすいようにオプションは設定していますが、画像サイズに合わせて多少の調整は必要な気がします。 画像サイズが小さくなると lineTolerance/splineTolerance が大きくなったときに穴ができてしまうので、閾値を小さくすると良いです。 といって小さくしすぎると圧縮率が下がるので strokeWidth で調整すると良いというのがだいたいのイメージです。 しかし改めてオプションを弄ってみても、ほとんど出力が変わらないケースが多いです。 それだけ洗練されたとも言えるのですが、なかなか難しい。

2024年10月29日火曜日

画像を SVG に変換する @marmooo/imagetracer を作った

画像を SVG に変換する @marmooo/imagetracer を作りました。 他の有名なツールとしては vtracer, potrace, SVGcode, imagetracerjs があります。 これは imagetracerjs の改良・高速化版です。

fontconv

OpenCV の限界

実のところ同じようなものを OpenCV を使って作ろうとしていました。 最初は 24bit color の画像に findContours アルゴリズムを適用し、 求めた輪郭から path を描写する方法を考えました。 これなら OpenCV の実装が流用できます。 ちなみに現実的には画像の情報量は多すぎるので、 cv.LUT() を使って簡易的な減色を行って情報削減することは必要です。 しかし作ってみたところ、24bit color で動く findContours は RETR_CCOMP (RETR_FLOODFILL) しかないので、 階層情報をきちんと取得できず画像を復元できないことあるとがわかりました。 また思いっきり減色しても輪郭数が数十万も残ってしまう問題が見つかりました。 findContours を自作するか、包含関係の推定を自作しないと厳しそうでした。

次に dilate で詳細な輪郭を抽出して 2値化し、RETR_TREE の findContours して、 輪郭ごとに平均色を算出する方法を考えました。 しかしこれは処理時間の大半を平均色の算出に持っていかれて、ボツとなりました。 findContours の処理で生成される内部配列を使えれば、 平均色の算出は理論上はかなり早くできるのですが、現実では使えないので駄目でした。 輪郭数は数万程度には抑えられる利点はありましたが、自作しないと早いものは厳しそうでした。 詰まるところ、findContours の実装などに色々と課題があるのですよね。 OpenCV はデファクトでしょうが、こういう問題は他にも結構あると思っています。

imagetracerjs の改良

さてどうしたものかと思って GitHub を探していて imagetracerjs を見つけました。 ラスター画像をベクター画像に変換することを bitmap tracing ということを、いまさら知りました。普通は image2svg だと思うじゃん…。 先に述べたライブラリもその後見つかったのですが、imagetracerjs は他より高速・高精度に動作するように見えます。 ただいかんせん実装が古くて使いにくいことが気になりました。そこで、 (1) 一部の不要な実装を削除して API を簡素化し、 (2) 減色と blur を外部化して汎用性を持たせ、 (3) Deno で使いやすいように ESM 化し、 (4) テストをたくさん書いて完璧な移植をし、 (5) ベンチマークをたくさん書いて高速化し、 (6) 減色処理をライブラリ化して複数選択可能にし、 (7) 生成 SVG を minify して出力するようにしたものを、 @marmooo/imagetracer として公開しました。

改良余地はかなりあるので、今後もたぶん色々改良すると思います。 たくさんテストとベンチマークを書いたので思ったより作るのに時間が掛かりました。 blur は削除してしまいましたが、このへんの前処理はそもそも他のライブラリを使ったほうが良いです。

オプションの改善

Wasm 化以外の速度改善は難しいと思うので、JavaScript 実装は細かな改善が今後の課題です。 いまはオリジナルの実装に付属していたオプションの見直しをしています。 例えばこんなことをやっています。

layering 廃止

オリジナルの実装ではエッジ検出の手法が 2つ用意されていて、layering オプションで切り替えます。 オリジナルの配列を使った実装ではあまり差がないので妥当ですが、TypedArray を使った実装に変更すると話が変わります。 色ごとに処理するよりまとめて処理するほうが圧倒的に早いのでオプションは廃止しました。 エッジ検出は最大 50倍くらい早くなっています。

mergePaths 追加

オリジナルの実装だとレイヤ (色) ごとに内部の path を別々にレンダリングするのですが、これはレンダリング速度が遅くなります。 そこで色ごとに path を 1つに merge する mergePaths を付けました。 SVG 生成速度は変わりませんが、ブラウザでのレンダリング速度は大幅に高速化できます。 手元の検証では 6倍速くらいになりました。

pathomit/linefilter 廃止 → filterHoles 追加

短い path をスキップする pathomit/linefilter オプションは高速化に役立つのですが、 ほとんど同じ処理をしているので 2つもいらない気がしますし、オリジナルの実装だと白抜きの穴ができてしまう問題があります。 細かな線を消すときには、その穴を補完する手段を用意しておかないといけません。 わかりやすいのは holed path の場合で、穴をなかったことにして親ノードの色で塗りつぶすことです。 これはおそらくオリジナルの TODO にも書いてあった課題です。

ただ non-holed path の場合、例えば前景と背景の輪郭部分では 1ドットだけ色が違うような領域が大量に発生し、 その 1ドットをどうやって補間すれば良いかの問題が起きます。 雑に考えると周囲 8マスを見てもっとも勢力の大きな色を借りれば良いと想像はでき、 これは issue #15 などでも報告されていました。 ただ non-holed path はエッジ同士が結びついて、大きさが大きくなったり最初の座標が変わる可能性があります。 オリジナルの実装はこれが原因で穴が空きます。 さらに言えば 短い non-holed path が隣り合っていたりすると、周囲から代替となる path を抽出するのが困難になります。 下手に取り除くとやはり穴になります。 この問題があるので、処理の起点は holed path で、その中にあるものを取り除くことに徹するほうが楽です。 他にも色々問題があって、色をマージする方式だと同じレイヤ内でもエッジが結びつく可能性もあります。 これはちょっとやそっとの処理では解決できないので、たぶんエッジ検出をやり直さないといけないでしょう。 結びついても別のエッジにするなら隣接ドットの置換で済みそうですが、実装は面倒臭そうです。

そこでまずは holed path に絞って filterHoles を作成することにしました。 まずは点の少ない holed path をリストアップします。 次に対応する non-holed path のレイヤ番号を知るために、path の points を 0/1 で bounding box の領域に書き込み、 bounding box の外側の点を起点として floodfill します。 その後、内側をインデックスカラー画像と照合することで対応するレイヤがわかります。 対応するレイヤがわかったら holed path より長さが短く、bounding box に含まれる path を探して削除します。 ちなみに 3x3 のサイズで □ の形ができるまでは holed path が存在せずレイヤ ID が一意に定まることを利用すると、 path length < 12 までは flooodfill をしないで済むので高速化ができます。 結果、見た目をほとんど変えずにファイルサイズを削減することに成功しましたが、意外と圧縮効果は低い (10% くらい) ともわかりました。 filterHoles オプションを作ったことで設定項目はかなり洗練されたと思います。

TODO

個人的には現状でも類似アプリの中では一番使いやすいかなあと思っているのですが、 あとは出力ファイルサイズがやや大きいところをなんとかしたいところです。 包含関係を持たず隣接しているだけのノードを良い感じにマージすればかなりサイズは小さくなりそうですが、結構難しそうです。 周囲が 8割くらい囲われていたら面積の多い方に色を寄せて path をマージしたりするのが良いかな?

Node.js でも動きますが、SVG を画像に変換して確認するテストが動かなかったです。 Deno なら動くのに test: false してリリースせざるを得ないのは負けた気分…。 制作物とはあまり関係のない resvg/sharp の細かなバグが原因なのかと思っていますが、JavaScript ランタイムの保証もなかなか難しいなと感じています。

2024年9月3日火曜日

画像減色ツール Color Reducer を作った

減色ツール Color Reducer を作りました。

作った理由は cv.LUT() で色数を減らせるはずと思ったのですが、 ネットで解説が見つからなかったので、確認のために作りました。 他にもたくさん作り方はありますが、cv.LUT() を使うと高速なはずです。



たとえばこんな感じの減色ができます。



特にひねりのないツールですが、D&D やクリップボードからコピー (Ctrl+V) など、 結構使いやすいようには作っているので、普段使いのツールとして重宝します。 opencv.js を使うと手軽に高速な実装ができるかなと思って作り始めたのですが、 今なら porffor に期待したほうがビルドサイズを抑えられて良いかも知れません。

減色アルゴリズムは 均等量子化、k-means、Median cut、Octree などがあるらしいです。 意外と少ない。良い感じのソフトで使っているのは Median cut が多いようです。 Median cut は細分化量子化法 (Tapered quantization) の一種で 他にも色々あるようですが、最初に雑に考えて作ったものは 均等量子化 (uniform quantization) でした。

Median cut も最適化すると結構早い

最初は cv.LUT() を使った均等量子化を確認するためだけに作り始めたアプリだったのですが、 きちんとした減色処理の実装が他のアプリで必要になってきたので、Median cut も Octree も JavaScript で確認用に実装しました。 ちなみに k-means quantization も実装はしてみたのですが、削除しました。 MSE の低さに利点はありますが、初期化の仕方、終了条件に曖昧性が高い問題があり、 なにより速度が絶望的に遅いです。

できる限り高速化してみると、たいていの画像は細分化量子化法と遜色ないレベルで動作しました。 Median cut や Octree の JS 実装としては鬼のように早いと思う。JavaScript もきちんと書けばできる子です。 一番の気付きは、RGB ずつカウント処理するより同時に Uint32Array で処理するほうが圧倒的に早いということです。 ただ RGB の平均色を求めようとしたりソートするときは変換コストが高いのか Array のほうが早かったりします。難しい。 結構色々な高速化を試したのでインパクトが大きかったものをまとめると以下です。
  • String Map → int Map (x3)
  • int Map → 2^24 Array (x2)
  • 2^24 Array → 2^24 Uint32Array (x2)
  • Array.sort() → bucket sort (x1.5)
  • Object → Array (+30%)
  • 色数のキャッシュ (+30%)
その他の改善ももちろん大切で、画像処理だと基本的にループの速度が命となります。 2^15 くらいまでのデータのループ処理は Array のほうが早いですが、それ以上は TypedArray のほうが早いです。 ループ処理の方法も、Array の for of は論外として、forEach は 2^16 くらいまで for と大差ないですが、2^24 だと明確に遅いです。 1920 x 1080 x 4 = 8294400 ≒ 2^23 なので forEach は使えません。 他に参考になったことは arr1.push(...arr2) です。 concat や 1個ずつの push と比較して高速ですが、Maximum stack call size に注意が必要です。 色数のソートに使っているのですが、理論上 65536 回呼び出す可能性があります。 spread 記法は Chrome だと 2^16 は許可されていますが、2^17 は駄目みたいで割と怖い。 駄目なら件数を絞って spread 記法を使うだけなんですが、2^17 でさえ弾かれたのはびっくりしました。

一番難しいと思ったのは、要素数が少ない時には通常の配列のほうが TypedArray の数倍くらい早いので、 小さなデータは多少のコストを支払ってでも通常の配列にしたほうが早いということです。 たとえば Uint32Array に入っている RGBA データをそのまま扱うより、シフト演算で Number を抽出して、通常配列に戻した上で処理するほうが早い。 このへんが Wasm との速度差になっているかもなあ。 他にも RGB を配列の添字で表現して抽出するより、RGBA 数字データからシフト演算で RGB を抽出するほうが早かったりするのは、データ構造を考える上では興味深かったです。

他にできそうなことはソート済みの色で min/max の計算を減らしたり、レンジをキャッシュしてバケットソートのロスを減らすなどの、地味な改善くらいでしょうか。 前者はループのキャッシュ効率のほうが影響が大きいため、ループを分けて書くと効果がありませんでした。 効果があるようキャッシュ効率を考えてコードを書くと行数がものすごく増えるので止めました。 あとは木構造のキャッシュで改善することはわかっていますが、劇的に改善する訳でもないので、いまは採用していません。 他にも Worker Threads の最適化も実験はしてみたのですが、 SharedArrayBuffer への複製コスト、Worker の起動コストが大きすぎて (50ms!)、重い処理以外は高速化できそうにありませんでした。 画像処理だと Web Worker でマルチスレッド処理しても遅くなることのほうが多そう。

画像の圧縮

さて公開しようかと思った矢先に ブラウザの toBlob() だと 出力オプションの設定ができないせいで、減色はできているのに PNG のファイルサイズが減らないことに気付いたので、 ファイルサイズの圧縮もできるようにしました。 設定なしで PNG8 にするのは困難としても、toBlob() の quality すら動かないのは如何なものか。

PNG

PNG の圧縮が地味に課題で、ライブラリに困りました。 古いライブラリだと Node.js 依存の pngjs か、 ESM 非依存と実装の問題がある UPNG.js あたりが使われるのかも知れません。 ただまあ上記の理由によりどちらも使いにくいです。 たまたま見つけたのだと image-js は使いやすいかも知れませんが、JS 実装です。 速度だけをみると sharp というのが凄いみたいですが、Node-API だからこれも使えないです。 将来的には livips の wasm として wasm-vips が有望なのかも知れませんが、少しファイルサイズがでかい気もします。 つまり何を使えばよくわからない。そんなせいもあってか自作している人もいるのですが ( 1, 2)、 これらもライブラリとしては使いにくい。

どうしたものかと思っていた時、denosaurs/pngs を見つけて採用しました。 使ってみた感触としても、シンプルでかなり良い感じです。 他には squoosh の Wasm や wasm-codecs を見つけましたが、 内部で使われている Oxipng はロスレス圧縮なのでインデックスカラーには対応していません。

GIF / JPEG

GIF にもインデックスカラーを使った減色方式があるのですが、ロスレス圧縮ですし、アニメーションなどを対応するのは難しいので今回はサポートしていません。 JPEG にはインデックスカラーがありません。 JPEG は mozjpeg / Guetzli / QOI など圧縮ツールによってアルゴリズムが違うのですが、 私はまったくこだわりがないので、ブラウザさんの出力する JPEG 画像にお任せしたい気持ちしかありません。 mozjpeg は Chrome の JPEG 実装より少し効率的らしいのですが、WebP のほうが効率的だしなあ。

今時使われる画像形式は、.png, .jpg, .webp (と .gif) くらいなので、ブラウザの機能を活かしてこの 3つだけサポートしました。 細かな圧縮オプションについては対応していたらキリがないし、無意味なオプションが増えるだけなので、最強圧縮だけサポートしました。 色々遊んでみましたが、今となっては画像圧縮ツールは Scratch のような WebP をサポートしていないアプリに対応するくらいの意味合いしかない気がします。 最近は WebP をサポートしていないアプリはだいぶ減ってきて、Blogger でさえサポートしました。 サポートしていないのは Scratch くらいに感じます。

ベンチマーク

他のアプリでも使うのでベンチマークを取ってみたところ、 opencv.js の Wasm と JavaScript で実装した均等量子化の速度が 4倍ありました。 やはり画像処理などでは Wasm のほうがかなり高速のようです。 それ以外の実装については、画像のサイズにもよりますが、何の推論も行わない均等量子化と比較して、 Median cut が 〜2倍、Octree が 〜1.5倍くらいの差しか生まれませんでした。かなり早い。 頻度を考慮した時に Octree より高速な分割処理は計算の省略以外ではほぼ考えられないので、ベースライン的な速度になりそうです。 重要なのは Median cut のような精度面の改善になりそうですが、k-means まで来ると使い物にならないです。

Wasm

既にかなり高速ではあるのですが、さらに 4倍近く高速化できそうなので、早く porffor を使って wasm 版も作りたいです。 既にコンパイルできることは確認しているのですが、Wasm exports したときにどうやら動かないので、引数で ImageData を渡して実行するのが難しいのが現状です。 何らしらの import/export 機能さえあれば、あとはちょっとしたコードの変換で動くものはできるはずですなので、早く欲しい。 JavaScript で Wasm を書ければ、下手に C や Rust で Wasm を書くより管理が楽そうなのも porffor の良いところですね。

ちなみに AssemblyScript も軽く試してみたのですが、TypeScript にした上でいくつかの記法をダウングレードしないといけなくて、気軽な変換は難しい印象でした。 Union と型エイリアスを使えないのが痛すぎる印象で、変更量がかなり多くなります。 ちなみに Wasm は元々クラスをサポートしていないので、クラスを使うにはインスタンスを生成するだけの関数を作って export するのが逃げ道のようです。 同じことは Rust を使った Wasm にも言えて、クラスのように扱おうとすると JavaScript 側でのラップが必要です。 オプションなども定義し直さないといけない問題があります。

まとめ

勉強ついでに雑に 1日で作るつもりだったのですが、median cut に手を出したら色々必要になってしまい、なぜか力が入ってしまったアプリになりました。 既存のアプリと比較しても高速・高機能なので、これからは自作の Color Reducer も使っていこうと思います。

2024年8月3日土曜日

画像を線画に変換する Lineart Converter を作った

AIを使わないで写真を線画へ変換するアプリ Lineart Converter を公開しました。 絵柄が変わることなく高速・省メモリに動作します。 線画以外も生成できます。素材生成にご利用ください。



何も設定しないでもこれくらいの変換はできます。 設定するともっと色々できます。



最近の AI は高性能なのでみんなが色々なことをやっていますが、 私はメインマシンのメモリが 4GB なので GPU ゴリゴリの話はあんまりなあと思っています。 ただ AI を使って色々やっている人のを見ていて、 画像から線画を作るくらいなら OpenCV で十分じゃないのと思ったので、 お手軽な線画変換ツールを作ってみました。色塗りで遊ぶくらいならこれで十分そうです。

Wasm + SIMD + Threads でサクサク動作しますが、JavaScript もかなり高速なので、1つでも処理をミスると JavaScript のほうが早かったりします。 たとえば白黒画像を透明化する機能も作ってはみたのですが (GUI にはないけど…)、JavaScript のほうが早かったです。 JavaScript は 24bit 画像に直接書き込めるので最適化しやすいですが、 OpenCV だと 8bit にしたり 24bit にしたり色々やることが多く最適化しにくいので、場合によっては遅くなるということです。 OpenCV の詳しいコードまで見ていないので正確なことはわかりませんが、 きちんと最適化すると案外 SIMD を使わなくても JavaScript のほうが早いケースが結構あるかも。

アルゴリズム

線画を作るアルゴリズムは Canny と adaptiveThreshold と dilate の 3つに大別できると思います。

Canny

Canny はベタ塗りや太い線が白抜きになり、また線が正確に抽出できなかったり、閉路になりにくい欠点があります。 特に線画正確に抽出できないのが今回は厳しいので、何らかの改善がないと利用は厳しいかなあと思っています。 改善手法などを軽く探していたら、計算過程を可視化した良いエントリがありました。 線画に適したものは初期段階で十分抽出できていますが、 途中で大量に drop することがわかります。線画には使えそうにないかな。

adaptiveThreshold

adaptiveThreshold はベタ塗りや太い線が保持され、線が正確に残る利点があります。 局所的な陰影を頑張って拾うのでたいていの写真で申し分ない二値化ができますが、 アニメ絵のように割とフラットな画像では、頑張った結果ノイズになることもあります。 境界の薄い画像は、灰色化した後に二値化することから、エッジが失われる可能性がそれなりにあります。 その場合は平凡な threshold で輝度をフィルターすると良いことが多いです。 cv.LUT() で輝度フィルターした後に adaptiveThreshold を 適用する方法も考慮の余地はありますが、そこまですべきかは微妙です。 threshold の輝度フィルターで色の薄い部分が除去できていると考えれば大差ない気もします。

dilate

dilate は 1回適用しただけで、濃淡の薄い画像も含めてほぼ完璧なエッジが得られる利点があります。 ただし見えにくいノイズがたくさんあって、またエッジが取れすぎてしまうので、除去が難しいことが欠点です。 結局は adaptiveThreshold か threshold を利用することになりそうです。 やはり adaptiveThreshold がうまくいくケースが多いですが、 ノイズが複雑なときは大局的なフィルターのほうが良い時もたまにあります。 薄い線をバキッと消したい時には threshold も使えます。

TODO

人間が調整する必要はありますが、AI と十分勝負はできてると思います。 課題があるとすれば、(1) ギザギザの線の平滑化、(2) 濃淡のないラフ画への対応などでしょうか。 1 は頑張れば解決できる気はしますが、2 は線を消すのが難しそう。 考えるべきことは色々ありますが、だいたい動くようになったので、いったんリリースです。

作ってみて改めて思ったのは、 AI を使わないで漫画風やアニメ風の画像を用意するのが、すごく大変ということでした。 雑にどこかの著作物である画像をサンプルに載せるのは簡単なのですが、 きちんと権利関係を処理しながら載せようとすると、サンプルを用意するだけでも大変でした。 AI を使うとそのへんの問題がサクッとしてしまうので、うーんという気分です。

2024年7月21日日曜日

簡易的な暗視カメラ Nocto Camera を作った

OpenCV の練習で簡易的な暗視カメラ Nocto Camera を作りました。 暗視カメラと書いていますが、露出不足の環境でも綺麗に撮影ができるカメラアプリです。 画像のコントラストを補正する画像アプリとしても使えます。 暗視カメラとか使ったことないし、使う人もそんなにいない気がするのですが、 今回は OpenCV のアルゴリズムの精度と速度を確認する勉強目的で作ったので、まあ良いです。



たとえばこんな感じになります。



アルゴリズムは CLAHE ヒストグラム平均化を利用しています。 類似アルゴリズムとしては ToneMap を使った HDR (High Dynamic Range Imaging) があります。

Tonemap

ToneMap は露出時間の異なる撮影を行った画像が複数枚必要なので、 CLAHE ほど手軽には使えないアルゴリズムなのかなと理解しています。 ToneMap を使うとしたら露出時間の異なる撮影データを自動で用意しないといけません。

Web 上だとカメラ撮影でも複数の撮影データを自動で用意するのはなかなか難しいです。 iPhone/Android の 自動 HDR はおそらく ToneMap を利用していますが、 カメラが 1つしかない iPhone 7 時代から HDR 機能はあるので、 露出時間を変えたものを同時に撮影して ToneMap をしていると予想できます。 ただこれと同じようなことを Web 上で実現しようとすると、 まずは exposureTime を設定してカメラを起動する必要があります。 iOS では exposureTime の設定自体が存在しないので、現状できません。 iOS 以外だとカメラの設定変更と撮影を繰り返せば実現はできそうですが、 切替に多少時間が掛かるため撮影に時間が掛かりそうです。

ビデオ撮影の場合、最近の iPhone のようにカメラが複数付いていれば、 露出時間をそれぞれ変化させて動画撮影して ToneMap させたり、 カメラの撮影条件を微妙に変えて推定できるのかなと思ったりしましたが、 ハード依存が強いのでパスしました。

CLAHE

さて CLAHE についてですが、完全に真っ暗の環境で Webcam 撮影した時は、あまりうまく行きませんでした。 暗すぎると物体があるかどうかすらきちんと判断できないので、 綺麗な表示をするためにはある程度の光源が必要です。 光源が強くないときちんと動作しないようでは暗視カメラとしては残念ですけね。

ただ表示の綺麗さを求めなければ、 clip limit を 0 にしたとき良い感じの暗視カメラとして動作します。 これは輝度値が 0/1 の差しかなくても反応するモードなのでノイズが非常に多いですが、物体も発見しやすくなります。 パソコンの黒画面程度の光しかない環境でも、近場にあるものは非常に綺麗にカラー判別できることがわかりました。 ちなみにパソコンの白画面程度の光があれば、数メートル離れたところにあるものは何となく判別できる、くらいの精度では動作します。

ノイズが大きいのは 8bit で処理しているせいもあるかも知れませんが、ビット数を増やすと処理速度の問題が出てきそうです。 確認していませんが、CLAHE は 16bit で処理させることもできるようです。clip limit = 0 の時には使えるかも?

equalizeHist, Gamma correction

equalizeHist() でもだいたい良い感じの結果は得られます。ただ明るい部分だけが明るくなりすぎるのが欠点です。 Gamma correction は色を暗い環境に合せて表示できるので、安定した結果が得られます。 ただ暗すぎるときは詳細を得られません。 色を変えずに詳細部分を強調したいなら CLAHE が向いていますが、暗すぎるとあまりうまく行きません。 そこである程度暗いときには equalizeHist() + CLAHE か、Gamma correction + CLAHE のように組み合わせるのが良い感じです。 詳細を得るにはやはり CLAHE はあったほうが良さそうです。 どちらのメソッドも 8bit で動くので、ノイズに関してはあまり深く考えないほうが良いかも。 ただこれらは 8bit でも影響は少ない気がします。

TODO

とりあえず現状でもまあまあ使えるかも知れませんが、カメラの起動オプションなどを弄ればさらに精度は上がる可能性があります。 ただ Web API からいじるのは現状だと不安定感があり、iOS のサポートもできないので、 現状ではこれ以上は欲張らないほうが良さそうです。 iOS が advanced options をサポートしたらまた改良してみます。

2024年7月2日火曜日

難読漢字一覧を作った

難読漢字辞書を作りました。 念のため書いておくと、地名・人名などの固有名詞を含まない辞書です。 それらを含むとキラキラネームで酷いことになるからね…。 まず問題意識としては、漢検準1級からは表外読みもテストに出題される仕組みがあります。 このとき準1級と1級のどちらにその漢字を載せるかの問題があります。 準1級に載せると数が多くなり過ぎてしまうので、レベル別に分けた難読漢字辞書があると良いなと思って作りました。 他にはない基準で辞書を作っていて、小学生にとっての難読漢字、中学生にとっての難読漢字、 高校生にとっての難読漢字、大人にとっての難読漢字の 4種類で分けています。 このほうがわかりやすいでしょ?



作り方

作り方は、まずは読み方が常用漢字の表内音訓で構成されるかどうかで判断します。表外音訓が含まれるものは自動的に難読にしました。 ただそれだけだと使い物にならないので、「他の字又は語と結び付く場合に音韻上の変化を起こす語」を解析する必要があります。 例えば常用漢字表には以下の例があります。
納得(ナットク) 格子(コウシ)手綱(タヅナ) 金物(カナモノ)
音頭(オンド) 夫婦(フウフ)順応(ジュンノウ)
因縁(インネン)春雨(ハルサメ)
これらの大半は連濁・連声・促音化・半濁音化で対応できます。 たとえばタヅナ→ツ+濁音(連濁)、ノウ→ン+オ→ノ(連声)、ン+エ→ネ(連声) で対処できます。 他にも発表(ハッピョウ)は、ツ→ッ(促音化)、ヒ→ピ(半濁音化)で対処できます。 こういった典型的な読み方の変化で構成される語は難読漢字とは言えません。

上例で難しいのは音韻変化のカナモノ、音韻添加のハルサメです。 このような読み方の変化は予測できないので難読漢字です。 他にも転音・音便・音韻脱落・音韻融合などは変化が不明瞭で、たぶん正確には予測できません。 他に難しいのは「取引、入口、場合、組合、立場、引換」などの送り仮名を省略した語句です。 この処理はたぶん形態素解析の知識を入れないと無理だと思うのですよね。 yomi-dict を使えば漢字一字の特殊読みを取得できるので一応は対処できましたが、 わかりやすいルールで処理できないので、日本語は本当に難しいなあと感じさせられました。

まとめ

完成品を見てみると、常用漢字レベルでも簡単に見えるものが多々ありますが、全体としては良い出来です。 常用漢字表は意外と訓読みが載っていないことが多いのだなと感じました。 また小1では「一人(ひとり)」、小2では「時計(とけい)」などが普通に出てくるので、 難読漢字だからといって学習から除外しないほうが良いこともすぐにわかります。 やはり語句の利用頻度を見て例文を作るほうが大切なのでしょう。

また思ったより難読漢字は数が少ないこともわかりました。 先の条件だと 8,000 くらい。 Unihan Database に登録されている音訓を表内と見なした場合は 3,000 くらい。 Unihan Database の音訓もまあまあ使えるんだなとわかります。

2024年5月11日土曜日

ぬりえもじを作った

ぬりえもじ を作りました。塗り絵+絵文字でぬりえもじ。 シンプルな塗り絵教材はそれなりにあるので、 完成品を横に置いて色を見比べながら色を塗ってもらうアプリにしました。 つまり色の感性を鍛えるアプリになっています。



色彩アプリと色覚異常

色に関するアプリは、色覚異常の人にとって割と鬼門のアプリです。 日本では男性の 5%、女性の 0.2% が色覚異常なので、色覚異常はかなり確率の高い症状です。 ちなみに北欧だとその確率は 2倍にもなるので、必ず考慮する必要があります。 とはいえ絵文字はユニバーサルデザインを考慮して作られているので、 適当な絵を使って作るより見分けやすくなっており、遥かに有用なゲームになっていると思います。

だいたい完成してから遊んでみると、色数が 8色以上になるとキツイと感じました。 8色以上になる頻度はそんなに多くないですが、私も色に強くないので…。 また、えもじパズルの時と同様に面積が小さすぎる部分がたまにあるので、 面積が全体の 5% 未満なら最初から色を塗っておくことにしました。 面積 5% 未満にあらかじめ色を塗っておくと、複雑な問題の均一化ができて、8色以上になる確率もかなり減って、なかなか良い感じです。

作り方

基本的な作り方は簡単で、すべての要素を fill=none にして、クリックで色を濡れるようにするだけです。 あとは細かなバグ潰しですが、一番困ったのは表示が他のノードで完全に隠されているケースです。 普通なら完全に隠されている場合は見えないので無視して良いのですが、radialGradient でグラデーションが掛かっていたり、 前面ノードが透過色であった場合には、裏にも色を塗る必要があります。 操作に一意性を持たせるには、グラデーションと透過色は除外して、 その背後にあるノードに色を塗るような処理を加える必要があります。

採点の時にも完全に隠されているノードは問題になります。 具体的には完全に合っているのに 100点が取れなくなります。 ノードごとに色を採点するのが簡単なので、比較すると冗長な処理になってしまいますが、 やはりピクセル単位で採点するのが一番安全と思います。 直感的な得点にはなるので、そこは良いところです。