コンテンツにスキップ

反転する地平、明滅する土着

Author:Kazukichi
巨大な粘土の塊に、恣意的な切れ目が入る。切り離された一塊に、漢字が結びつく。甲骨文字から三千年あまり、切れ目は増え続け、やがて増えるのをやめた。残ったのは一万字あまり。
画像の階調を文字のインク量へ写し取る、アスキーアートの系譜がある。百に満たない字を濃さの順に並べると、像が戻ってくる。字は量として使われ、素性は問われない。階調から分節へ。字は量ではなく、形状として使われる。互いに似ない形がいくつも立つ。
奥行きは消え、連続は離散へ落ち、部分が全体になる。起伏が生まれ、瞬きのあいだに移り変わる。見えなかった揺らぎが、土壌になる。
移り変わる速さは、一様ではない。遅い一帯が場所になり、速い一帯が流れになる。反転した地平の上で、土着が明滅する。そこにもなお、意味は結ばれうるのか。
  • 本格的な個人作品としての二作目で、能面を題材にした一作目の 静謐な武装の顕現 が伝統的な記号の読み替えだったのに対し、今回は構造の転換に重心を置いている
  • カメラに映った映像を、日本語の文字の集合へリアルタイムに置き換え続ける
  • 鑑賞者が自分の体を動かすと、自分を写していた文字の像が崩れて別の文字に変わる
  • リポジトリ: https://github.com/oborodice/inverted-horizon
  • デプロイ先: https://oborodice.github.io/inverted-horizon/
  • 人は本質的に無意味な世界に意味を見出して暮らしているが、空間方向と時間方向に捻じ曲げた世界に対しても同じように意味付けをしてしまうのか
  • 無意味であるというのは、意味が対象に内在せず人の側が割り当てているという見方に拠った仮定
    • 壁の染みが顔に見えるように、見ることは本質的に何かとして見ること
  • 無意味だと主張するのではなく、仮定は変えずに条件だけを変え、観測者の振る舞いが変わるかを見る実験
  • 取り払ったのは空間について三つ、時間について一つの計四つである
    • 現実の空間が三次元であるのに対して、作品は二次元
    • 現実の空間が連続しているのに対して、作品は一文字ずつに分節されている
    • 現実の空間が画角の外へ続いているのに対して、作品では切り取られた一部が全体になる
    • 現実の時間が滑らかに流れるのに対して、作品では移り変わりが跳躍になる
  • カメラ映像も二次元の平面でピクセルという単位にも刻まれているが、どちらも名目上でしかない
    • 陰影や、奥へいくほど模様が細かく見えるテクスチャ勾配が載っているため、見る側は自動的に立体へ復元してしまう
    • その単位は写っているものと何の関係もない均質な格子で、一つ取り出しても何も意味しない
  • このうち三つは、二値化・グリッド化・漢字への近似という同じ操作で落ちている
    • 二値化が陰影を、グリッド化がテクスチャ勾配を消すため、手前と奥が同じ平面の形として並ぶ
    • 分節の単位が、その無意味な格子から、一文字で何かしらを表す文字へ移る
    • 文字の集合が離散的であるため、時間方向の移り変わりも跳躍になる
  • 画角だけは操作の結果ではなくカメラに元から備わっているもので、どこに置いたかは問題ではなく、囲われていること自体がそこを一つの小宇宙にする
  • 四つが揃って裏返るため、世界を見るときに前提としている地平そのものが反転する
  • 漢字は意味の割り当てを人類が長い時間をかけて積み上げた台帳で、一文字で何かしらを表すため、語に組み立てなくても一字ずつがそれだけで世界の断片として自立する
    • アルファベットでは一文字が意味を担わないため、同じようには成立しない
  • 世界を分節してラベルを貼った台帳は一つではなく、日本に住む自分の眼差しを通すために、日本の文化圏で使われる文字を集めた
    • 漢字が支配的で、かなやローマ字、ASCII文字のように日本で日常的に使われる文字も入れているため、一文字で何かを表すものから音の部品でしかないものまでの幅はある
    • 台帳が日本の文化圏のものである以上、立ち上がるのは普遍的な世界ではなく、日本語が切った世界になる
  • 一つの文字とその近傍が連なって織りなす生態系が立ち上がる
  • 暗いところには画数の多い文字が、明るいところには空白に近い記号が集まり、遠目には濃淡が起伏として読める
  • 隣り合っていても揃わず、それぞれ別の文字として立つため、連なりには多様性がある
  • 動かないところでは文字がそのまま居続けて根付き、動くところでは入れ替わり続けて通り過ぎていく
  • その差が、人が土地と出来事を区別するのと同じ仕方で、地面と流れとして読める
  • 現実では知覚できない揺らぎが、この生態系の土壌になる
  • 土着は一枚の画面には存在せず、見ている時間の中にしか無いため、静止画では成立しない
  • 以下では詳細な手順としてではなく、設計上の判断 / つまずいた点 / 面白い発見を中心に紹介する
  • 事前準備として、対象の文字を低い解像度の白黒ビットマップへレンダリングし、どのピクセルに色が付くかを計算したパターンDBを用意しておく
  • カメラ映像をグリッド状のブロックに分割し、各ブロックをパターンDBと同じ大きさに縮小して切り出す
  • 切り出したブロックを二値化して白黒のビットマップにする
  • ブロックごとにパターンDBと比較して、完全一致ではなく最も近いパターンを持つ文字を引く
  • 引いた文字を、そのブロックが画面上で占める実際のピクセルサイズでフォントから改めて描き直す
  • 本体が毎フレームcanvasへ直接描画し続ける命令的なループで、宣言的UIの再レンダリング機構とは相性が悪いため、フレームワークは使わずVanilla TypeScriptとViteで組んだ
  • 漢字はJIS X 0213(第1〜第4水準)の約1万字を採用した
    • Unihanデータベース には、その字が日本の規格由来であることとその出典を記録する kIRG_JSource というフィールドがあり、これを持つ字を抽出するとほぼ機械的に同じ集合が得られる
    • 1978年の最初のJIS漢字コードの策定時に参照資料の誤字・誤植がそのまま採用され、出典が不明なまま規格に残った幽霊文字12字も、この抽出に含まれる
  • ひらがな / カタカナ / 半角カタカナ / ASCII文字 / 全角英数記号と、日常的に書く字ではないが卒塔婆や護符として日本の生活に残っている梵字(真言密教で使われる悉曇文字)の92字もあわせて採用した
  • 日本発祥で馴染みの深い絵文字は、カラーグリフ専用のフォント形式が必要で同じ描画パイプラインに乗らず、白黒へ二値化すると主な情報である色を失って判別困難な塊に潰れるため、対象外にした
  • 一方でフォントにグリフが無く豆腐状になる字は、描ける字だけに揃えると採用した文字集合ではなくなるため、あえて除外していない
  • 日本語のドットマトリクスフォントで伝統的な16x16では、画数の多い漢字ほどストロークが潰れて別々の字が同じパターンになりかねないため、ビットマップ解像度は24x24にした
  • フォントは梵字以外の全てを Noto Sans JP 、梵字を Noto Sans Siddham でカバーし、どちらもRegularに固定している
    • この解像度では明朝体の細い縦画や「うろこ」が潰れやすく、線の太さが均一なゴシック体のほうがパターンの再現性で有利
    • ユーザのOS標準フォントに頼ると、同じコードポイントでも環境ごとに字形が変わってしまう
  • Webフォント配信用の @fontsource が配布するWOFF2はグリフを間引いたサブセットで、対象の漢字のうち37%が欠けていたため、 @expo-google-fonts のnpmパッケージからフルの .ttf を取り出して使っている
  • 距離の計算をビット演算だけで済ませるため、24x24の576ビットを Uint32Array の18要素に詰めて持っている
  • 当初は画面全体で1つの固定しきい値を使っていたが、明暗に偏りのある場面では暗い側が丸ごと黒、明るい側が丸ごと白に潰れ、その領域にあるはずの輪郭が失われる
  • 自動で決める方法として、まず輝度ヒストグラムをクラス間分散が最大になるように2クラスへ分けるOtsu法を、フレーム全体で1回だけ計算する実装を試した
    • 通常のライブ映像では固定しきい値とほぼ同じ見た目で、カメラを動かすと判定が安定しなかった
    • クラス間分散が各クラスのピクセル数の割合で重み付けされているため、背景が支配的な場面では、本来の境目よりも背景内部のノイズを2つに割る境界のほうが分散が大きいと判定されてしまう
  • ブロックごとにOtsu法を適用する局所適応版も試したが、こちらは体感で明らかに質が落ちた
    • 各ブロックが自分の内部分布だけを見て2分割するため、画面のどこが明るくてどこが暗いかという階調構造がブロック単位で正規化されて消えてしまう
    • これはOtsu法固有ではなくブロック単位で局所正規化するという方針自体に起因するため、ピクセルごとに周辺ウィンドウの輝度からしきい値を決めるBradley法やSauvola法も同様の問題を抱えると見て、優先度を下げた
  • 2つの検証を経て、欲しかったのは最適な二値分割ではなく、展示中に明るさが変わってもしきい値が追従することだったと気づき、フレーム全体の平均輝度をそのまましきい値にする方式に落ち着いた
    • 平均輝度も画面全体で1つのしきい値を出すものなので、明暗に偏りのある場面で片側が潰れること自体は変わっていない
  • 濃淡を点の密度で表現するディザリングは、中間輝度のブロックを空間的に散らばった点群に変換し、黒いピクセルがまとまってストロークを形成している実際の文字と似ないため、入れていない
  • カメラ側のブロックとパターンDBの各文字を、どちらも576ビットのビット列として突き合わせ、値が異なるビットの数を数えるハミング距離が最小の1字を選ぶ
  • 2つのビット列をXORすると異なるビットの位置だけが1になるため、その1の個数(popcount)がそのままハミング距離になる
  • popcountにはPOPCNTという専用のCPU命令があるが素のJavaScriptからは呼べないため、ビット演算だけで数える方式を3つ比べた
    • ブライアン・カーニハンのアルゴリズムは n & (n-1) で最下位の1を1つずつ消していく方式で、計算量が1の数に比例するため、黒いピクセルの多い密なパターンでは素朴な全ビット走査とあまり変わらなくなる
    • SWAR(SIMD Within A Register)は1つの整数レジスタを複数の区画に見立て、通常のビット演算だけで全区画をまとめて処理する方式で、1の数に依存しない固定回数で終わる
    • ルックアップテーブル方式は、0〜255の各値のpopcountを事前計算した256要素のテーブルを8ビットずつ4回引くことで、同じく1の数に依存せず、演算の総数はSWARより少ない
  • ホットループで何百万回も呼ばれるなかで、256バイトというテーブルの小ささならCPUキャッシュに載り続けやすく、メモリアクセスのコストもほぼ無視できると考え、ルックアップテーブル方式を採用した
  • テーブルを引く区切りは8ビットにしており、16ビットではテーブルが65,536要素に膨らんでL1キャッシュに収まらない可能性が出る一方、4ビットでは32ビット分の参照回数が8回に増える
  • ここまでで1回の比較は速くなったが、グリッド40x30の1,200ブロックに対して候補が約1万字あると、総当たりでは毎フレーム1,200万回の比較になり、そのままでは成立しない
  • ハミング距離の計算は変えず、その手前に候補を減らす段を置く方向にし、まずpopcountによる枝刈りを入れた
    • |popcount(A) - popcount(B)| <= ハミング距離(A,B) という三角不等式が成り立つため、popcountが現時点の最良距離以上に離れた候補は最近傍になり得ず、576ビットの本格比較をせずに除外できる
    • 候補をpopcount順に並べておき、入力に最も近い位置から両方向へ広げ、差が最良距離以上になった方向で打ち切る
  • popcountの値は0から576までしかないため、約1万件を均等に散らせば1値あたり平均18件前後に絞れる見積もりだったが、実際に数えると固定パターンで約430件、ノイズ動画では99%以上が絞り込めず約1万件が残っていた
    • 打ち切り条件は最良距離が縮まって初めて効くが、構造を持たない入力ではどの候補との距離も576ビットの半分程度にしかならず、最良距離がほとんど縮まらない
    • popcountが1の総数しか見ておらず、ビットの位置という構造的な類似性を無視していることが根本の原因
  • 代わりにビットの位置を見るLSH(Locality-Sensitive Hashing、局所性鋭敏型ハッシュ)へ切り替えた
    • 576ビットのうちランダムに選んで固定したk箇所の値だけを並べたkビットを署名とすると、よく似たパターンは大半のビットが一致しているため同じ署名になりやすく、カメラ側のブロックと署名が一致した文字だけを厳密比較すれば済む
    • kを大きくすると署名の種類が増えて似ていない候補をよく弾けるが、本当に近い候補も選んだ1箇所の食い違いで取りこぼしやすくなるため、別のk箇所を選んだ表をL個用意し、どれかで一致した候補を集めることで補う
    • k=10・L=8で、最も厳しいノイズ動画での実測値が約1.8fpsから約13fpsまで改善した
  • 基準点からの距離で候補を層に分け、三角不等式で層ごとにまとめて除外していく木構造のBK-tree(Burkhard-Keller tree)も候補に挙がったが見送った
    • 次元数が増えるほど大半の点同士の距離が似た範囲に収束して三角不等式での除外が効かなくなる次元の呪いの影響を強く受け、画像を短いビット列に落とす知覚ハッシュでよく使われる64ビット程度に対して、この作品のパターンは576ビットある
    • そもそもpopcountは「全部0のビット列」とのハミング距離そのものなので、popcountによる絞り込みは基準点を1つしか持たないBK-treeとみなせ、階層を増やしても各段階の枝刈りが弱まっていくだけで伸びしろが小さい
  • 最初のボトルネックは、canvas上のピクセルを配列として取り出すCanvas 2D APIの getImageData で、GPUからCPUへの同期的な読み出しのため1回あたりのオーバーヘッドが大きい
  • ブロックごとに呼んでいたため、既定のグリッド40x30では毎フレーム1,200回の呼び出しになっていた
  • カメラ映像をブロックごとに1枚のアトラスcanvasへ描き集め、 getImageData はアトラス全体に対して1回だけ呼ぶ形に変えた
  • 各ブロック分は、取り出したピクセル配列から1行ずつコピーして切り出すようにした
  • 実測値は約1.2fpsから約2.7fpsへ改善したが、起動直後は速く出るのにしばらく経つと低下してそのまま張り付く、という経時的な劣化も見えてきた
  • アトラスへの drawImage もブロックごとに呼んでいたので、カメラ映像全体を一括で縮小する1回にまとめ、実測値は約9〜10%の改善だった
  • 前述した劣化を詳しく測ると、再読み込み直後の約3.5〜4.6fps / しばらくして約2.7fpsで安定 / さらに経つと約1.2fpsまで低下して張り付き、という3段階の推移だった
    • 張り付く先の約1.2fpsは集約前の値と同じで、時間が経てば改善が丸ごと失われることになる
  • 最初はGC(ガベージコレクション)の圧迫を疑った
    • 毎フレーム ImageData を1,200個生成しており、すぐ不要になるはずのオブジェクトが旧世代へ昇格して重いフルGCが頻発している、という筋書き
    • 生成源を4箇所潰しても張り付く先の値は一切動かず、この説は否定された
  • ブラウザを変えると、Chromium系のBraveやChromeでのみ起き、Firefoxでは起きなかった
  • Playwright でヘッドレスの自動計測を組んだところ、3分間まったく劣化しなかった
    • ヘッドレスでは行われない「実際に画面へ描画・合成する処理」に紐づくという切り分けになり、ヘッドありで計測し直すと実機とほぼ同じ挙動が再現した
  • CDP(Chrome DevTools Protocol)でトレースを取って集計すると、GPU上のピクセルをCPUへ読み戻す処理で getImageData の実体と見られる RasterDecoderImpl::DoReadbackARGBImagePixelsINTERNAL が突出していた
  • 件数は安定区間の77件/6秒から、張り付いた後は15,290件/7秒へと約198倍に増えていた
    • 実際のフレームレートとは全く対応しない1秒あたり約2,200回という頻度で、休みなく発生し続けている状態
  • 原因は、ブロック切り出し用に作ったアトラスcanvasのコンテキストを willReadFrequently なしで取得しており、GPU上のサーフェスに描画される状態になっていたこと
    • canvas.getContext('2d', { willReadFrequently: true }) としてCPU側のバッキングストアを持たせると、同じ3分の計測で劣化しなくなった
  • 引き金は累積の呼び出し回数が一定を超えることだと見ているが、なぜそこで切り替わるのかという内部の仕組みまでは特定できていない
    • 検証はApple Siliconの実機だけで行っており、Chromium全般の問題なのかこの環境に固有なのかも切り分けられていない
    • ただしApple SiliconはCPUとGPUでメモリを共有していて読み戻しは本来有利なはずなので、ハードウェアの限界ではなく、ChromiumがmacOSで描画に使うMetalバックエンド側の事情だと見ている
  • 解像度やグリッド密度を振ったスイープでは、目標フレームレート30の上限にあたる28.8fpsまで出ていたのに、実カメラ・実環境では6〜7fpsしか出ず、大きく食い違っていた
  • 計測には、実カメラの代わりにテスト用の映像を流すChromiumの疑似カメラを使っていたが、それが映る <video> 要素から直接ピクセルを読むと、常に真っ黒で video.currentTime も0のまま進んでいなかった
    • video.srcObject を設定するだけで video.play() を呼んでおらず、挙動がブラウザごとに一貫しない autoplay 属性頼みになっていた
    • 毎フレーム同一のビットパターンしか生まれず、文字のキャッシュがほぼ100%ヒットする、現実離れした最も軽いケースを測り続けていたことになる
  • 修正後も実環境との差が残ったため、 ffmpeg でピクセルごとに独立な乱数で埋めた、情報量が最大のノイズ動画を生成して流し込む方式にした
    • Chromiumの疑似カメラは固定の組み込みパターン1種類しか出せず、実カメラに比べて毎フレーム似たビットパターンしか生まないことを確認した
  • 以降は、疑似カメラの固定パターン(最も易しい) / ノイズ動画(最も厳しい) / 実カメラ(その中間)の3つを参照点として扱い、ボトルネックの特定にはノイズ動画を基準にしている
  • 改善の前後を別の時間帯に測って効果を約4倍と誤判断したこともあり、本機はロードアベレージが常時10前後と高く時間帯で振れるため、before / afterの比較は必ず間を置かず測るようにした
  • 1920x1080・グリッド10という最もブロック数の少ない構成だけ、ブロック数が桁違いに多い構成より遅いという逆転が起きていた
  • 自前のJS関数を順に計測しても、フレーム時間の95%以上がどこにも計上されない場所で消えていた
    • 文字描画をOFFにすると実測値が約9.0fpsから28.8fpsまで回復したため、原因は文字描画だと確定した
  • canvasに文字を描く fillText の呼び出し自体は速く、その後メインスレッド外のコンポジタやGPU側で行われる非同期のラスタライズが、次のフレームの開始を遅らせていた
    • このグリッドでは1ブロックが約192x180pxと大きく、少数の巨大な文字を含むレイヤーのラスタライズがブラウザ内部で不釣り合いに重くなっていると考えられる
  • 本実装の前に、1回だけ描いたビットマップを元と同じ位置・サイズで drawImage するだけの経路を試した
  • 文字描画をOFFにしたときと同じ28.8fpsまで回復し、同じ面積を塗り替えているのにこの値に戻ったため、重いのは文字のベクター輪郭をラスタライズする処理そのものだと分かった
  • そこで、同じ文字 / フォントサイズ / 色の組み合わせを初出時にだけラスタライズし、以降は貼り付けるキャッシュを実装した
  • 逆転していたグリッド10も目標フレームレートの上限である28.8fpsへ張り付き、グリッド40の構成も同じ値に達したため、文字描画由来のボトルネックは解消した
  • 文字ごとに個別のcanvasを作った版では表示が欠けることがあり、Chromiumが同時に保持できるcanvasの数の上限を超えて古いものが黙って空にされていたため、1枚の大きなcanvasへ行単位で詰め込むアトラス方式に変えた
  • キャッシュはグリッドや解像度を変えるたびに増え、グリッド数を全範囲掃引する計測ではJSヒープで約137MB、プロセス全体のRSS(実際に確保されている物理メモリ量)で約277MBの純増が出たため、変更の時点で空にしている
    • 展示では自動でグリッド数を変え続ける想定であり、8時間規模の連続稼働では無視できない量だと判断した
  • 前述したLSHへの切り替えで最大の改善は済んでおり、ここから先に残っていたのはいずれも構造を大きく変える案だった
  • WebAssemblyへの移植は、実装と計測まで進めたうえで見送った
    • ハミング距離の計算をまるごと AssemblyScript で書き直すと、ノイズ動画では約25%速くなったが、固定パターンではほぼ差がなかった
    • 早期終了が効く固定パターンでは1呼び出しあたりの実作業が小さく、JSとWASMの境界を跨ぐコスト自体が計算コストと同程度になる
    • ノイズ動画で差が出たのは、前述したPOPCNT命令のようにJavaScriptからは届かないハードウェア命令がWASM側では使えるためで、特別な命令を使わない単純な数値ループではV8のJITが既に十分最適化しており恩恵は小さい
    • 同じロジックをJS版とAssemblyScript版の2箇所で恒久的に同期させ続ける二重実装のコストに、フレーム全体で数ポイントという効果が見合わなかった
  • ブロック全体を走査する3つのループを1つに融合する案は、試作して約5〜8%の一貫した改善が出たものの、抽出 / 二値化 / ビットへの詰め込み / マッチング / 描画という5つの異なる関心事を1つの関数が背負うことになるため見送った
  • Web Workerによる並列化は、 requestAnimationFrame を前提とした同期的なループを、あるフレームで切り出したブロックを次以降のフレームで描くことを許容する非同期パイプラインへ作り変える必要があり、構造変更の規模が大きいため保留した
  • マッチングをGPUへ移す案は、既存のCanvas 2Dとは別にWebGLを導入する必要があるうえ、早期終了のような分岐で速くする作りと、多数の要素へ同じ命令を一括で流すGPUの実行方式は相性が悪いため保留した
  • 複雑さをほぼ増やさずに効く小さな改善を優先し、大規模な構造変更は一旦保留する、という方針に転換している
  • この作品は、常にパフォーマンスとの戦いだった
  • 映像を扱う作品だからGPUと相性が良いとは限らず、あえてCPUで計算させる選択を取ることもあった
  • ボトルネックをCDPのトレースで実測できたので、推測で終わらせずに深掘りできたのは良かった
  • 展示する端末をある程度選ばないと成立しなさそうなのが、今後の課題として残っている