# browser-image-enhancement 設計メモ

ブラウザ上で動く画像補正ライブラリ。サーバーに画像を送らず、クライアント側だけで明るさ・色・シャープネスなどの補正を行うことを目指す。

> 実装前に書いた設計メモです。使い方は [README](../README.md) を参照してください。
> v0.1.0 の実装で設計から変えた点は末尾の「実装メモ（v0.1.0）」にまとめています。

## 目標と非目標

**目標**

- 依存ライブラリなしで、モダンブラウザ（Chrome / Edge / Firefox / Safari の最新2バージョン）で動く
- `HTMLImageElement` / `HTMLCanvasElement` / `ImageBitmap` / `ImageData` / `Blob` を入力として受け付ける
- 補正を複数つなげても、画質劣化（中間での 8bit 丸め）と処理時間を抑える
- 重い処理は Web Worker で実行し、メインスレッドを止めない
- TypeScript の型定義付き、ESM で配布、tree-shaking 可能

**非目標（当面やらない）**

- レイヤーやマスクを持つ画像編集ソフト的な機能
- RAW 現像、HDR、16bit 以上の出力
- 機械学習ベースの超解像やノイズ除去（将来の拡張候補として未決事項に記載）
- Node.js 単体での実行

## API の形

関数型とパイプライン型の両方を提供し、内部は同じ実装を共有する。

### 1. 関数型（単発の補正向け）

```ts
import { brightness, contrast } from 'browser-image-enhancement';

const out: ImageData = contrast(brightness(imageData, 0.1), 0.2);
```

- 各関数は `ImageData` を受け取り、新しい `ImageData` を返す（入力は変更しない）
- 一番小さな単位で、テストしやすく tree-shaking も効く
- 連続して呼ぶと補正ごとに 8bit へ丸められる点はドキュメントで明示する

### 2. パイプライン型（複数補正・プレビュー向け）

```ts
import { pipeline } from 'browser-image-enhancement';

const p = pipeline()
  .brightness(0.1)
  .contrast(0.2)
  .saturation(-0.1)
  .sharpen({ amount: 0.5, radius: 1 });

const canvas = await p.run(img, { output: 'canvas' });
const blob = await p.run(img, { output: 'blob', type: 'image/webp', quality: 0.9 });
```

- 補正の列を宣言的に保持し、`run` のときにまとめて実行する
- 色補正（点処理）は連続していれば 1 パスに融合し、中間で丸めない
- パラメータだけ変えて再実行できるので、スライダーでのプレビューに向く
- `p.toJSON()` / `pipeline.fromJSON()` で設定を保存・復元できる

### パラメータの規約

- 量を表す値は原則 `-1〜1`（0 で無変化）に正規化する。例外（ガンマ、半径など）は単位を型と JSDoc に書く
- 不正値は例外ではなく範囲内に丸め、開発ビルドでのみ警告する

## 処理方式

**方針**: 補正処理は Web Worker 上の JS 実装で行うことを基本にする。メインスレッドは入出力の受け渡しだけを担当し、UI を止めない。

| 方式 | 長所 | 短所 | 用途 |
| --- | --- | --- | --- |
| Web Worker + JS | UI を止めない、どこでも動く、結果が決定的、複数 Worker で並列化できる | GPU より遅い | **既定のバックエンド** |
| メインスレッド + JS | 構成が最も単純 | 大きな画像で UI が固まる | Worker が使えない環境のフォールバック、テストの正解値 |
| WebGL2 | 速い | float32 なので JS と 1 階調ずれることがある、GPU メモリを使う | スライダー操作中の原寸表示（`createGpuRenderer`、2026-10-02 追加） |
| WASM (SIMD)（Worker 内） | CPU で速い、結果が決定的 | ビルドが複雑、配布サイズ増 | 将来の高速化オプション |
| WebGPU | 最も柔軟で速い | 対応ブラウザがまだ限定的 | 将来の候補 |

### Worker まわりの設計

- 補正の実装は DOM に依存しない純粋関数として書き、メインスレッドでも Worker でも同じコードが動くようにする
- メインスレッドでは入力を `ImageBitmap` か `ImageData` に正規化し、ピクセルの `ArrayBuffer` を **transferable** として Worker に渡す（コピーしない）
- Worker 側でパイプラインを実行し、結果の `ArrayBuffer` を transfer で返す。Blob 出力は Worker 内の `OffscreenCanvas.convertToBlob` で作る
- Worker はライブラリ内部で `new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })` として生成し、利用者が Worker のファイルを自分で配置しなくて済むようにする
- 大きな画像は横帯（タイル）に分割して複数 Worker（既定は `navigator.hardwareConcurrency` を上限）で並列処理する。畳み込み系は半径分の重なりを付けて分割する
- スライダー操作中のプレビューでは、新しい要求が来たら古い要求の結果を捨てる（最新の要求だけ反映する）
- `run(img, { worker: true | false })` で切り替え可能、既定は `true`。Worker が作れない環境（CSP で禁止など）では自動でメインスレッドにフォールバックする
- 関数型 API（`brightness(imageData, 0.1)`）は同期でメインスレッド実行のまま残し、Worker を使うのは非同期のパイプライン API とする

### 色空間

- 色補正はリニア RGB で計算する（sRGB のまま計算すると明るさやぼかしで色が濁るため）。sRGB ↔ リニア変換は 256 要素の LUT で行う
- 内部表現は `Float32Array` とし、最後に一度だけ 8bit に戻す
- 入出力は sRGB 前提。Display P3 などの広色域対応は未決事項

### モノクロ画像の扱い

ブラウザはグレースケールの JPEG / PNG も RGBA（R=G=B）に展開して渡すため、ライブラリから見るとカラー画像と同じ形で届く。そのうえで次のように扱う。

- **判定**: 入力時に全ピクセルが R=G=B かどうかを調べ（Worker 内で 1 パス、途中で異なる画素が見つかれば打ち切り）、結果を `isMonochrome` としてメタ情報に持つ。`run(img, { colorMode: 'auto' | 'rgb' | 'gray' })` で明示もでき、既定は `auto`
- **処理**: モノクロと判定した画像は輝度 1 チャンネルだけで計算する。メモリと計算量が約 1/3 になり、丸め誤差で R/G/B がずれて色が付くことも防げる
- **色に関わる補正**: 彩度と色温度はモノクロ画像に対しては何もしない（無変化）。色を付けたい場合は `colorMode: 'rgb'` を明示する。開発ビルドでは「モノクロ画像に色補正を指定した」旨を警告する
- **出力**: モノクロ入力の結果は R=G=B のまま返す。輝度だけの `Uint8ClampedArray` を取り出す `output: 'gray'` も用意する
- **アルファ**: グレースケール + アルファの PNG も同様に扱い、アルファチャンネルは補正しない
- **16bit グレースケール PNG**: Canvas 経由のデコードでは 8bit に落ちる。当面はこの制約をドキュメントに書くに留める
- **テスト**: モノクロ入力でどの補正をかけても出力が R=G=B のままであることをテストで保証する

## 補正の一覧と優先度

### 第1段階：点処理（ピクセル単位、1パスに融合可能）

- 明るさ（brightness）
- コントラスト（contrast）
- 露出（exposure、EV 単位）
- ガンマ（gamma）
- 彩度（saturation）／自然な彩度（vibrance）
- 色温度・色かぶり（temperature / tint）
- レベル補正（levels: 黒点・白点・中間）
- トーンカーブ（curves: 制御点からスプライン補間して LUT 化）
- グレースケール、セピア、反転

### 第2段階：近傍処理（畳み込み）

- ぼかし（ガウシアン、分離可能フィルタで実装）
- シャープ（アンシャープマスク）
- ノイズ除去（メディアン、バイラテラル）

### 第3段階：画像全体を見る処理

- ヒストグラム取得（UI 表示用にも公開）… 実装済み（`histogram`、[dra.md](dra.md)）
- 自動レベル・自動コントラスト（ヒストグラムの両端をクリップ）… 実装済み（`autoStretch`、DRA。[dra.md](dra.md)）
- 自動ホワイトバランス（グレーワールド仮定）
- ヒストグラム平坦化／CLAHE

### 範囲外だが関連する補助機能

- リサイズ（補正前の縮小プレビュー用）
- EXIF の回転情報の反映（`createImageBitmap` の `imageOrientation` を使う）

## 性能の目安

- 12MP（4000×3000）画像に第1段階の補正を 5 個かけて、Worker 4 並列で 300ms 以内、単一 Worker で 1 秒以内
- 処理中もメインスレッドのフレーム落ちが起きないこと
- プレビュー用途では縮小画像で処理し、確定時のみ原寸で処理する運用をドキュメントで推奨する

## 開発環境（予定）

- TypeScript、Vite（ライブラリモード）でビルド、ESM + 型定義を出力
- Vitest でユニットテスト（JS バックエンドは jsdom 不要の純粋関数としてテスト）
- Worker 経由の実行は Playwright で実ブラウザ上の結果をメインスレッド実行と比較
- `demo/` にスライダーで補正を試せるデモページ
- GitHub Actions で lint・型チェック・テストを実行

## ディレクトリ構成（予定）

```
src/
  index.ts            公開 API
  pipeline.ts         パイプライン本体と融合処理
  io.ts               入力の正規化（Image/Canvas/Blob → ImageData）と出力
  color/              sRGB↔リニア変換、LUT
  ops/                補正ごとの定義（パラメータ型、JS 実装、GLSL 断片）
  worker/             Worker のエントリ、メッセージ定義、タイル分割と並列実行
demo/
test/
```

## 進め方（マイルストーン）

1. 雛形（ビルド・テスト・CI）
2. 関数型 API と第1段階の点処理（JS 実装）＋デモ
3. パイプライン API と点処理の融合
4. Web Worker 実行（transfer、タイル分割、並列化、フォールバック）
5. 第2段階（ぼかし・シャープ）
6. 第3段階（ヒストグラム・自動補正）
7. npm 公開

## 決定事項

| 項目 | 決定 |
| --- | --- |
| パッケージ名 | `browser-image-enhancement`（npm で未使用を確認済み、2026-10-01） |
| 最初のリリースの範囲 | 明るさ、コントラスト、露出、ガンマ、彩度、色温度、レベル補正の7つ。トーンカーブ、vibrance、色かぶり、グレースケール変換、セピア、反転は次のリリース以降 |
| 色空間 | 当面は sRGB のみ。Display P3 などの入力は sRGB に変換して処理し、その旨をドキュメントに明記する |
| 高速化 | 全補正を原寸でスライダーに追従させるため、表示用に WebGL2 の経路を追加する（2026-10-02 に決定、当初の「WebGL2 は見送り」から変更）。正確な結果（保存など）は JS 版で作り、WebGL2 が使えない環境は JS 版の縮小プレビューに戻す。CPU の計算は 2026-10-08 に WebAssembly（SIMD）を追加（JS 版とビット単位で一致、「実装メモ（WebAssembly）」）。WebGPU は見送り |
| 機械学習ベースの補正 | 本体には入れない。必要になったら別パッケージにする |
| ライセンス | MIT |
| モノクロ画像 | 入力として考慮する（「処理方式」節の「モノクロ画像の扱い」） |

## 実装メモ（v0.1.0）

設計からの変更点と、実装して決めた細部。

- **内部表現**: `Float32Array` の作業バッファではなく、1 画素ずつ JS の数値（float64）で計算し、最後に 1 回だけ 8bit に丸める。作業バッファが要らないのでメモリは入出力分だけで済む
- **点処理の融合**: 先頭に並ぶチャンネル単位の補正（彩度以外のすべて）は 256 要素の表に畳み込む。末尾に並ぶチャンネル単位の補正は「丸めのしきい値」を逆算して丸めと一体化する（各補正が単調増加であることを利用。結果は畳み込まない計算とビット単位で一致することをテストで確認）。彩度を含まない列は 8bit → 8bit の表引きだけになる
- **レベル補正**: ヒストグラムの見た目と合うよう、黒点・白点・中間ガンマは sRGB の符号化値（0〜1）で指定・適用する。中間で 8bit には丸めない
- **コントラスト**: sRGB 50% グレーを支点にしたリニア空間のべき乗カーブ。-1 で全面グレー、+1 で 2 値化
- **明るさ**: 正で白に、負で黒に近づける（リニア空間）
- **色温度**: R と B のゲイン（+1 で R×1.4、B×0.6）を白の輝度が変わらないよう正規化
- **モノクロ判定と並列化**: 横帯に分けて複数 Worker で処理するとき、`auto` では各 Worker が自分の帯を判定し、メインスレッドが全体の結論を決めてから処理させる（帯ごとに判定が割れて色温度が一部だけ掛かる、ということが起きない）
- **転送**: 入力は変更しない約束なので、帯ごとに 1 回コピーしてから transfer する。コピーは帯ごとにイベントループへ処理を返しながら行い、メインスレッドを長く止めない
- **Blob 出力**: 帯を結合したあとメインスレッドの `OffscreenCanvas.convertToBlob`（なければ `canvas.toBlob`）で作る。エンコード自体はブラウザが非同期に行う
- **ビルド**: Vite のライブラリモードではなく、ソースと同じファイル構成のまま ESM と型定義を出す（当初は `tsc`、モノレポ化で共通パッケージを取り込むため `tsdown` の unbundle モードに変更）。Worker は `new URL('./worker.js', import.meta.url)` で参照するので、Vite や webpack 5 など利用側のバンドラがそのまま扱える（Vite でバンドルした利用側アプリが Worker で動くことを Playwright で確認）
- **CDN 配信**: ブラウザは別オリジンの URL から Worker を起動できないため、CDN 用に `dist/cdn/` へ Worker 同梱の ES モジュールと IIFE を Vite のライブラリモードで別途出力する（Worker は `?worker&inline` で文字列として埋め込み、`blob:` URL から起動）。`dist/index.js` を CDN から直接読んだ場合は、`import "<worker.js の URL>"` だけを書いた同一オリジンの Blob を Module Worker として起動する。どちらも別オリジン（`127.0.0.1` のページから `localhost` のファイル）で Worker が起動することを Playwright で確認
- **性能（参考値）**: 12MP・補正 5 個（彩度・色温度を含む）で、Node の単一スレッド約 0.4 秒、ヘッドレス Chromium（4 コアの共有サンドボックス）でメインスレッド 0.8〜1.0 秒、Worker 4 並列 0.6 秒前後。数値は計測環境の揺れが大きい

## 実装メモ（シャープ）

- **方式**: アンシャープマスク（`amount`・`radius`・`threshold`）。ぼかしはガウス（`radius` が標準偏差）で、`ceil(3 × radius)` px で打ち切る分離型（横 → 縦）の畳み込み
- **どの値で掛けるか**: レベル補正と同じく sRGB の符号化値で掛ける（Photoshop などと同じ見た目になり、`threshold` も階調で考えられる）。輪郭は符号化値の輝度（Rec. 709 の係数）で検出し、同じ差分を R・G・B に足す。色ごとに掛けるより色のにじみが出ず、計算もぼかし 1 回で済む。モノクロ画像は 1 チャンネルのまま
- **パイプラインの中での扱い**: シャープの位置で画像を `Float32Array` の面（符号化値、0〜1 に丸めない）に展開する。シャープより前の補正は従来どおり表と 1 画素ずつの計算、後ろの補正は丸めと一体化した表（符号化値 → リニアへの変換も含めて畳み込む）で計算するので、8bit への丸めは最後の 1 回だけのまま。シャープを含まないパイプラインはこれまでと同じ経路で、速度も変わらない
- **透明画素**: ぼかしは α で重み付けし（重みの合計で割る）、α = 0 の画素は数えず、変えもしない。地図の nodata の縁が暗くなったり明るくなったりしない。画像の外側も α = 0 と同じ扱い（端の画素を引き伸ばすのではなく、内側の画素だけで平均する）
- **継ぎ目を出さない**: 各画素の結果は `margin`（シャープごとの `ceil(3 × radius)` の合計）px 以内の入力だけで決まり、どこで切っても同じ順番で足し算するので、帯やタイルに余白を付けて処理すれば全体を一度に処理した結果とビット単位で一致する。Worker の横帯は上下に `margin` 行を重ねて送り、自分の行だけを使う（DRA の統計も自分の行だけで数える）。外側を透明で埋めても結果が変わらないので、タイルの端（画像の外側）も同じ扱いで済む
- **行ごとに流す**: シャープが 1 つのとき（ほとんどの場合）は画像全体を float の面に展開せず、`2 × ceil(3 × radius) + 1` 行のリングに 1 行ずつ前段の補正を掛けて読み込み、ぼかしてマスクを掛けた行から順に 8bit にして書き出す。作業メモリは数行分だけになり、メモリの読み書きが大きく減る。足し算の順番は面に展開する場合と同じなので結果はビット単位で一致する（テストで比較）。シャープが 2 つ以上ある場合は従来どおり面（1 画素あたり 12 バイト、RGB）に展開する
- **不透明な行の省略**: α がすべて 255 の行は重みが全部 1 なので、重みのぼかしは前もって同じ計算で求めた値を使い、輝度のぼかしだけを計算する（結果は同じ）
- **性能（参考値、ヘッドレス Chromium・4 コアの共有サンドボックス、12MP の写真風画像）**: 半径 1 で Worker 約 0.5 秒（改善前 0.65〜1.0 秒）、メインスレッド約 1.2〜1.3 秒（改善前 1.5〜1.9 秒）。半径 5 で Worker 約 0.7〜0.8 秒（改善前 1.0〜1.5 秒）。比較として、露出だけ（表引きのみ）は Worker で約 0.08 秒で、これが Worker への受け渡しの下限。残りはほぼ計算そのもの（ぼかしの積和と 8bit への丸め）
- **WebAssembly について**: 2026-10-08 に追加した（「実装メモ（WebAssembly）」）。12MP の半径 1 は Worker で 0.4 → 0.19 秒

## 実装メモ（スライダー用プレビュー）

- `createPreviewRunner({ maxSize })` は入力を長辺 `maxSize` に 1 回だけ縮小し（ブラウザの高品質な縮小）、同じ入力の間は使い回す。各実行は `pipeline.scaled(縮小率)`（シャープの半径を縮小率倍にする）を縮小版に掛ける
- 計測（12MP の写真風画像、ヘッドレス Chromium・4 コア、Worker）: 点処理 5 個は長辺 1280 px で 9 ms、彩度を足して 27 ms、DRA とシャープまで足して 89 ms。原寸ではそれぞれ 86 / 246 / 888 ms。点処理は原寸でも 0.1 秒以内だが、彩度（チャンネルを混ぜるので表に畳めない）、DRA（統計の往復）、シャープ（近傍の積和）は原寸ではスライダーに追従しない
- 原寸のままでスライダーに追従させるには GPU（WebGL2 / WebGPU）が必要。WASM SIMD は計算部分で 2〜3 倍程度で、原寸の全補正で 0.3〜0.4 秒程度にとどまる見込み

## 実装メモ（GPU / WebGL2）

- **API**: `createGpuRenderer({ canvas })` → `setImage(image, { colorMode })` を 1 回、`render(pipeline)` を値が変わるたびに。結果は canvas に直接描かれ、画素の読み戻し（`read()`）は必要なときだけ。WebGL2 か `EXT_color_buffer_float`（float テクスチャへの描画）がなければ `null` を返し、呼び出し側が JS 版に戻す
- **計算の対応**: 各補正のシェーダは ops/index.ts と同じ式（入力の sRGB → リニアは 256 要素の表、出力は符号化して四捨五入）。シェーダは補正の種類の並びと色モードだけで決まり、値は uniform なので、スライダーを動かしてもシェーダの再コンパイルは起きない
- **パス構成**: シャープがなければ 1 パス（入力 → 全補正 → 8bit）。シャープがあると、シャープの直前までを符号化値の float テクスチャに書き、横方向のぼかし（α 重み付きの輝度と重みの和）→ 縦方向のぼかし・マスク・次のシャープまでの補正、を繰り返す。最後に結果のテクスチャを上下反転して canvas に描く。画像の外側と α = 0 の扱いは JS 版と同じ
- **モノクロ・DRA**: 色モードの判定（`auto`）と DRA の統計は CPU（JS 版と同じ関数）で行う。統計は画像ごとに 1 回だけ数えて使い回す
- **JS 版との差**: GPU は float32、JS 版は float64 で計算するため、丸めの境目にある画素が 1 階調ずれることがある。13 種類の組み合わせ（点処理、彩度、レベル、ストレッチ、DRA、シャープ 1〜2 回、透明画素、モノクロ、色モードの強制）で、最大 1 階調・ずれた値の割合 0.01% 未満であることをテストで確認（ヘッドレス Chromium の SwiftShader）
- **性能**: この開発環境には GPU がなく、ヘッドレス Chromium の WebGL2 は CPU 上のエミュレーション（SwiftShader）になるため、12MP で 0.56 秒（点処理＋彩度）〜 1.9 秒（シャープ半径 3）と JS 版（Worker で 0.41〜0.76 秒）より遅く、GPU の実力は測れていない。実際の GPU では、1 画素あたりの計算は数十〜数百回の演算で、12MP なら全体で数 ms〜数十 ms 程度の見込み（未計測）。デモで GPU を有効にすると 1 回あたりの所要時間（GPU の完了待ちを含む）が表示される

## 実装メモ（WebAssembly）

2026-10-08、「WebAssembly や WebGPU でもっと速くしたい。余地があるか検証して、行けそうなら試す」という依頼で追加。

- **どこに時間がかかっていたか（12MP、1 スレッド、変更前の JS）**: 表引きだけの補正 約 0.08 秒、彩度など色を混ぜる補正 約 0.45 秒、シャープ（半径 1）約 1.0 秒、彩度 + シャープ 約 4 秒。表引きはメモリの読み書きでほぼ頭打ち。彩度は 1 画素ずつの float64 計算と 8bit への丸め（Quantizer）、シャープはぼかしの積和と丸め、彩度 + シャープは符号化（`Math.pow`）が大半
- **方式**: 計算の中心（wasm/kernel.c）を C で書き、clang で wasm32 + SIMD128 にする。Emscripten などは使わず、libc なしでビルドし、`Math.pow` の代わりに musl の pow（wasm/pow.c、Arm optimized-routines 由来、誤差 0.54 ULP、MIT）を同梱。モジュール（約 20 KB）は base64 で src/core/wasm-bytes.ts に埋め込むので、パッケージは JS ファイルだけのまま（CDN の 1 ファイル版もそのまま）
- **JS 版と同じ結果**: float64 の計算を JS と同じ順番で行う（`a * b + c` を FMA にしない `-ffp-contract=off`、SIMD は画素の並びに沿って 2 画素ずつで、1 画素の中の足し算の順番は変えない）。丸め（Quantizer）は JS の表をそのまま渡して同じ探索をする。違いうるのは pow の最後の 1 ビットだけで、それも float32 の行バッファに入る値なので結果に出るのはまれ。ランダムなパイプライン 120 通り（シャープあり・なし、透明画素、モノクロ）と 12MP で全画素一致をテストで確認
- **符号化の高速化**: 彩度のあとにシャープがあるとき、行バッファに入れる値（`linearToSrgb` の結果を float32 にしたもの）は、64 区間の 5 次テイラー展開（相対誤差 約 1e-14）で求め、float32 の丸めの境目に近いときだけ pow で計算し直す。float32 にしたときの値は pow で計算した場合と必ず同じ
- **WebAssembly で動かす範囲**: 表と彩度の組み合わせ（`pixel_rgb`）、シャープ 1 回とその前後の表・彩度（`sharpen_stream`、行を流す JS 版と同じ構成）。表引きだけ（`lut8`）は JS と同じ速さでコピーの分だけ損なので JS のまま。下がる補正（反転したレベル・カーブ）が間にあるもの、シャープ 2 回以上、強制モノクロで色付き画素があるシャープは JS のまま
- **メモリ**: 1 画素ずつの補正は 1 MB ずつコピーして処理するので、作業メモリは数 MB。シャープは画像全体を WebAssembly のメモリに置いてその場で処理する（Worker の帯なら帯の分だけ）
- **読み込み**: Worker と Node では同期でコンパイルし、最初の画像から使う。同期コンパイルを断るブラウザ（メインスレッドに大きさの上限がある場合）では裏でコンパイルし、その間は JS。CSP に `'wasm-unsafe-eval'` がないとコンパイルできないので JS（`wasmStatus()` が `unavailable`）。シャープは 1 回の呼び出しを 26 万画素ずつの行の帯に分けており、ブラウザが途中で最適化済みのコードに切り替えられる（1 回目の実行が 1.4 秒 → 0.95 秒）
- **性能（ヘッドレス Chromium・4 コアの共有サンドボックス、12MP の写真風画像、5 回の中央値、JS → WebAssembly、ミリ秒）**:

| パイプライン | メインスレッド | Worker（4 本） |
| --- | --- | --- |
| 露出 + コントラスト（表引きのみ、JS のまま） | 104 → 88 | 90 → 86 |
| 色温度 + 彩度 + コントラスト | 615 → 309 | 245 → 149 |
| シャープ 半径 1 | 1408 → 488 | 405 → 190 |
| シャープ 半径 3 | 1921 → 589 | 594 → 226 |
| 色温度 + 彩度 + シャープ 半径 1 | 2408 → 989 | 645 → 321 |

- **ネイティブとの比較**: 同じ計算を x86 のネイティブ C（clang -O2）で動かしても彩度の補正は同程度の時間で、WebAssembly はほぼネイティブの速さが出ている。これ以上は計算の方法を変えない限り大きくは縮まない（Worker では受け渡しの約 0.08 秒が下限）
- **WebGPU について（見送り）**: 原寸のスライダー表示はすでに WebGL2 の経路（`createGpuRenderer`、地図は描画時の補正）で GPU が使われていて、WebGPU にしても同じ画素の計算をするだけで速さはほぼ変わらない。WebGPU で新たにできるのは計算シェーダ（ヒストグラムなど集計の GPU 化）だが、DRA の統計は画像ごとに 1 回だけで時間の大半ではない。保存用の正確な結果は float64 の CPU 版で作る方針（GPU は float32 で 1 階調ずれうる）なので、そこにも使えない。ヘッドレス Chromium では SwiftShader の WebGPU しか使えず速さも測れないため、必要になったら実機で再検討する

## 実装メモ（16bit を GPU で引き伸ばす）

2026-10-08。OpenLayers の描画部をどこまで手を入れる価値があるか調べた。`correctTiles: false` の地図は、タイルを 8bit にしてから WebGL に渡し、描いた canvas を `createGpuRenderer` で補正していた。16bit 画像では、引き伸ばし（DRA）が変わるたびに全タイルを JS で 8bit に作り直していた。

- タイルを元の値のまま float RGBA（バンド選択済み、隠す画素は alpha 0）でレイヤーに渡し、`GpuCorrectedTileLayer` が style の `color` 式でバンドごとに `(v - black) / (white - black)` を計算する。black・white は style の変数（uniform）なので、引き伸ばしを変えてもシェーダは作り直さない
- 引き伸ばしが変わってもソースのキーは変えない（`changed()` だけ）。統計がそろう前のタイルも先に描き、変数 `ready` が 0 の間は透明にしておく
- 補間は元の値で行ってから引き伸ばす（QGIS と同じ）。以前は引き伸ばした 8bit を補間していたので、ノイズだけのバンドを強く引き伸ばしたときは見た目が少し違う。なめらかな画像では 2 以内で一致する
- 必要なもの: WebGL1 の `OES_texture_float` と `OES_texture_float_linear`。ないときは従来の 8bit タイル。タイルのテクスチャは 4 倍（256² で 1 MB）になる

ヘッドレス Chromium（SwiftShader）、16bit 4096×2048、1600×1000 の画面で測った値（ms）:

| 操作 | 8bit タイル | GPU で引き伸ばし |
| --- | --- | --- |
| パンして DRA を取り直す | 1150〜1350 | 550〜700 |
| DRA の設定変更（同じ範囲） | 130〜160 | 120〜140（描き直しだけ。ソフトウェア GL の描画時間がほぼ全部） |

ほかに検討したもの:
- 補正（露出・コントラストなど）も同じシェーダで掛ける: いまは描いた 8bit の canvas に掛けているので、引き伸ばしと補正の間で 1 回丸める。シェーダに入れれば丸めは最後の 1 回になるが、`createGpuRenderer` と同じ式を OpenLayers の式言語で書き直すことになり、シャープは近傍を読むので後段に残る。差は 8bit の 1 段以内なので見送り
- WebGL2 の 16bit 整数テクスチャ: OpenLayers は WebGL1 で、Float32Array 以外の型をテクスチャにできない
- 本当に重いのは、新しいタイルを読むときの geotiff.js の展開と CPU 再投影（パン 1 回あたり主スレッド 0.3 秒ほど）。次の候補


## 実装メモ（パン時の主スレッド）

2026-10-09。16bit 8192×4096（EPSG:4326 を Web Mercator に表示）で、ズーム 4 回とパン 4 回の CPU プロファイルを取った。主スレッドの JS は約 5.4 秒で、内訳は CPU 再投影 2.4 秒、geotiff.js がタイルを値ごとに `DataView` で読む展開 0.8 秒、OpenLayers の `composeTile_` 0.6 秒、float RGBA への並べ替え 0.6 秒だった。

- 再投影: ソースタイルのつなぎ合わせ（行のコピー）だけ主スレッドで行い、三角形の描画はライブラリの Worker で行う（`reproject` メッセージ）。Worker が使えなければ従来どおり主スレッドで描く
- 展開: バイト境界の 8/16/32/64 bit で、マシンと同じバイト順、リサンプルなしの読み込みは、タイルを型付き配列として見てコピーする（サンプルが連続していれば `set`）。それ以外は geotiff.js に任せる
- 結果: 主スレッドの JS は約 2.6 秒。640×400 の画面ではズームとパンの合計が 3.0〜3.2 秒から 2.5〜2.6 秒になった。1600×1000 では、この環境のソフトウェア GL の描画（1 フレーム 200 ms ほど）が大半を占め、タイルが分かれて届く分だけフレーム数が増えるので、合計時間はほぼ変わらない
- 残り: `composeTile_` と RGBA への並べ替えはどちらもメモリを読むだけの処理で、まとめても大きくは縮まない
