Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Tauri v2で作る⼀括画像ブラー / Bulk image blur created wi...

Sponsored · Ship Features Fearlessly Turn features on and off without deploys. Used by thousands of Ruby developers.
Avatar for Atom Atom
August 10, 2026

Tauri v2で作る⼀括画像ブラー / Bulk image blur created with Tauri v2

Avatar for Atom

Atom

August 10, 2026

More Decks by Atom

Other Decks in Programming

Transcript

  1. なぜ Tauri v2 か 軽量(バイナリ小・メモリ小) Rust バックエンド(型安全・ネイティブなピクセル処理) OS 標準 WebView(Chromium

    を同梱しない) Skia FW 言語 描画 アイドルRAM バンドル Electron JS Chromium ~150–300MB ~85–150MB Tauri v2 Rust OS WebView ~30–80MB ~0.6–3MB Wails v2 Go OS WebView ~10MB* ~15MB* Flutter Dart Skia ~20–35MB ~18MB+ Avalonia C# Skia C++ ネイティブ 数MB〜 Qt 低 数十MB 同梱 ~50–70MB =アプリが自前でピクセルを描く 2D 描画エンジン(全 OS で同一の見た目)/ネイティブ=OS 標準ウィジェットで描画(OS 純正の見た目・挙動) 8
  2. メモリ優位の前提と限界 は WebView2 = Chromium → 実行時RAMは Electron と大差なく、計測 法次第で逆転もある

    差が明確なのは macOS / Linux(OS が WebView を共有し専有コピーを持たない 分、実効メモリが小さい) 代表ベンチは測定法依存かつ N=1(実アプリ1本の単機計測)で、一般化には注意 Windows 代表実測: Electron ~200–300MB / Tauri ~30–40MB(最小〜小規模アプリのアイドル時, OS 標準ツール計測)。N=1 出典=levminer(実アプリ Authme を単機計測)。測定法の争 点=Tauri #5889(RSS が Chromium 共有メモリを二重計上、PSS/USS で結果が変わる) 9
  3. Tauri v1 → v2: 何が変わったか モバイル対応(iOS/Android)が目玉 — 本アプリは未使用だが v2 の中核

    コア機能のプラグイン化 — 本アプリの tauri-plugin-dialog も v2 プラグイン セキュリティ刷新: v1 の allowlist → Capabilities / Permissions ( capabilities/default.json ) IPC 強化: Channel でストリーミング(進捗 ExportProgress に活用) 設定 tauri.conf.json 再編・Rust API 変更で v1 とは非互換(移行作業が必要) 10
  4. プラグイン化・権限・IPC — 何がうれしいか 概念 プラグイン化 Capabilities / Permissions (Inter-Process Communication/

    プロセス間通信) IPC 何か コア機能を本体から切り出し、必要分だけ導 入する仕組み 「どの window が どの API を呼べるか」を宣 言的に制限する権限モデル Web(JS) ↔ Rust の別プロセスを繋ぐ呼び出 し・データ交換 うれしさ 使う機能だけ=軽い・攻撃面が小/本体と独 立に更新・拡張 最小権限を強制/WebView が触れるネイテ ィブ API を限定・監査可能 重い/危険な処理を Rust に隔離/型付き境 界・ Channel で逐次配信 プラグイン化=機能を crates/npm のように付け外し。使わない機能を同梱しない→軽く安全、コミュニティ拡張が容易/Capabilities/Permissions=v1 の allowlist(全体 ON/OFF)を window×API 単位の細粒度 ACL に(permission=個々の許可、capability=束ねて window に付与) 。XSS/サプライチェーン被害を限定、JSON で監査可/IPC= WebView と Rust は別プロセス。invoke() で呼び出し、Channel/Event で逆方向。v2 の Channel は1件ずつ stream(逐次UI更新に活用。キャンセルは Channel の機能ではなく cancel_export +AtomicBool フラグで実現) 11
  5. ピクセル処理の言語・ライブラリ選定 (ブラウザ標準の描画API)でも描けるが、JS(プレビュー)と Rust(書き出 し の二重実装は見た目がズレる → Rust に一本化、同一コードで 見たまま出力(WYSIWYG) 型安全・ネイティブ速度・単一バイナリも両立。各言語のライブラリは?

    Canvas ) エコシステム 主ライブラリ C/C++ OpenCV / libvips / Skia Node sharp(libvips) / jimp Python OpenCV / Pillow Rust image / imageproc Go imaging / bimg(libvips) 種別 ネイティブSIMD native / 純JS ネイティブbinding 純Rust 純Go / native .NET ImageSharp / SkiaSharp managed / native Java Java2D ConvolveOp managed 速度帯 最速(gold standard) sharp最速 / jimp ~40–50×遅 速い 中(SIMD薄い) まちまち 中 低(分離実装なし) 12
  6. 技術選定の妥当性と弱み 妥当な点: メモリ安全+単一バイナリ(libjpeg 等 C 依存を同梱せず配布容易)+ プレビュー/書き出しで同一実装(WYSIWYG) 弱み: 最速ではない —

    事実上の標準は OpenCV/libvips。 imageproc は rayon 並列 はあるが SIMD 前面設計ではない(特定条件で数倍改善の報告あり) なぜ今 imageproc か: image crate と統合され依存最小・実績十分で安定。速度が 問題化した時の差し替え先は用途別の特化ライブラリ — ブラーは libblur 、縮 小は fast_image_resize (YAGNI) 並列化の2軸 — rayon=処理を複数コアに分散(Rust のデータ並列ライブラリ。 par_iter() 等)/SIMD(Single Instruction, Multiple Data)=1命令で複数画素を同時計算(1 コア内)。OpenCV/libvips は SIMD も駆使、 imageproc は rayon 中心 13
  7. 最終的な技術選定(結論) 結論: 本アプリの要件では Tauri v2 がベスト 決め手は処理速度ではなく「配布・安全・WYSIWYG」 想定規模では速度が問題になりにくく、一般層への単一バイナリ配布が効く 速度がボトルネックになったら Rust

    圏内で差し替え( imageproc → libblur ) もし要件が… 超大規模・巨大画像で低メモリ サーバ / CLI バッチ GPU・リアルタイム映像 中規模・配布重視の GUI(=本件) より適する選択 libvips 系(sharp / bimg / NetVips) Python+OpenCV / Go+bimg C++/Skia / wgpu Tauri v2 × Rust 14
  8. 責務分割: Rust ⇔ Web 境界の判断軸は「ピクセル/ファイルシステム(FS)に触るか」— 触る処理はすべて Rust へ集約 ピクセル実装は Rust

    に1つだけ → プレビューと書き出しが構造的に一致 (WYSIWYG) Rust (pixels / FS) ║ blur / decode / encode ║ repository: FS I/O / cancel ║ domain: filter/save/preset ║ Web (UI / state) orchestration / history keybindings / i18n / save domain: EditHistory/SaveConfig 境界は IPC の 1 本(Web = React 18 + Vite 5) 16
  9. IPC 境界 Web (JS) Rust #[command] │ invoke("export_batch", {args}) │

    │─────────────────────────────────>│ camelCase→snake_case・spawn_blocking │ ExportProgress {done,total} │ │<╌╌╌╌╌╌╌╌╌╌╌╌ Channel ╌╌╌╌╌╌╌╌╌╌╌╌│ 1 ファイルごとに逐次 │ Result<ExportOutcome> │ │<─────────────────────────────────│ 完了数・キャンセル・失敗一覧を返す コマンド load_images / generate_preview / export_batch / cancel_export — pixel は imaging(codec/blur)、ストレージ I/O は repository(DI) へ委譲 境界の仕事は実行手順の組み立てと共有状態の管理 — spawn_blocking への退 避、キャンセルフラグ、プレビューキャッシュ、進捗の逐次送出 ファイル単位の失敗・キャンセルはエラーではなく結果 — ExportOutcome が生エ ラーを保持し(握り潰さない)、 Err は前提検証とインフラ障害のみ 17 4
  10. spawn_blocking — なぜ UI が固まらないか 重い同期処理を専用スレッドへ逃がす関数 ( tauri::async_runtime , tokio)

    直接実行だと async ワーカーを占有し IPC/イベントが詰まる → 退避すれば即解放 され、プレビュー更新・キャンセルなど UI が応答し続ける spawn_blocking = 直接実行: async worker |==== blur / encode / write ====| spawn_blocking: async worker |-> await 即解放(他の IPC を処理) block thread |==== blur / encode / write ====| IPC/イベント滞留 → UI 停止 完了後に結果を返す =Rust で最も広く使われる非同期ランタイム。 async タスクのスケジューリングとワーカースレッド管理を担い、 spawn_blocking 用のブロッキング専用スレッドプール も提供する(Tauri が内部で採用)。 tokio 18
  11. IPC 境界を外殻とするレイヤリング(テスタビリティ) ┌──────────────────────────────────────────────────┐ │ commands / types │ │ imaging(codec/blur)

    repository(port+local_fs) │ │ domain (filter/save/preset) │ └──────────────────────────────────────────────────┘ 外殻: IPC 境界(repository を DI) アダプタ: 純粋コーデック/ストレージ・ポート 内核: 純粋コア(image/FS/Tauri 非依存) 依存は内向き 1 方向(外側 → 内核 domain )、逆はない domain は image/FS/Tauri 非依存 → #[cfg(test)] で同ファイル完結 repository が FS を隔離 → imaging も純粋コーデックに(in-memory で差し替 えテスト) [lib] 分離で GUI 起動なしに domain/imaging/repository をテスト #[cfg(test)] テスト可) =テスト時だけコンパイルする Rust の印(実装と同ファイルに書く)/ [lib] =アプリをライブラリとして切り出す Cargo.toml 設定(GUI 本体と別にビルド・ 19
  12. 外殻・アダプタ・内核のディレクトリ構成 Rust バックエンド src-tauri/src/ ├ main.rs, lib.rs ├ commands.rs, types.rs

    ├ domain/ ├ imaging/ └ repository/ Web 起動・層宣言・IPC ハンドラ登録 IPC 境界(調整+契約型・repository を DI) 純粋コア: filter, save, preset 純粋コーデック+カーネル: codec, blur ストレージ・ポート+アダプタ: mod(ImageRepository), local_fs フロントエンド src/ ├ app/ ├ ipc/ ├ domain/ ├ features/ └ shared/, i18n/ オーケストレーション(App.tsx) IPC 境界ミラー: commands, types 純粋コア(TS): EditHistory, SaveConfig 機能: asset-catalog, editor, batch 横断: keybindings, 翻訳 20
  13. 課題: プレビューが「別物」になる 2 つのズレ 実装のズレ: JS(Canvas) でプレビュー・Rust で書き出し → 1章の

    Rust 一本化で解 消済み 解像度のズレ: プレビューは縮小・書き出しはフル → 同一実装でも同じ半径で結 果が変わる 同じ radius=20 を当てると… 書き出し 4000px の画像に r=20 → 画像幅の 0.5% プレビュー 1600px の画像に r=20 → 画像幅の 1.25%(2.5 倍ぼける) 本章の WYSIWYG = プレビューで決めた強度が、フル出力でも同じ見え方になる こと 22
  14. プレビュー 1 回の経路 Web slider → debounce 130ms → invoke

    generate_preview{…, maxDim, reqId} Rust ├ cache hit → 縮小ベースを再利用 └ cache miss → read → decode → downscale(長辺を 1600px へ) → cache へ格納 → apply_stack_scaled(base, stack, scale) → PNG → base64 data URL Web 応答を現在の選択と照合(古い応答は捨てる)→ <img src={dataUrl}> 重い前処理はキャッシュ、毎回走るのはブラー+PNG、UI 側は間引きと採否だけ 以降のスライドで、上記の図の工夫した点を1つずつ — 競合制御 / スケール補正 / キャッシュ =連続入力を間引き、最後の入力から一定時間経ってから 1 回だけ送る仕組み。スライダーを端から端まで動かしても、送信は指を止めた 1 回だけ。130ms は「即応 と感じる 0.1 秒前後」かつ連打をまとめられる帯/1600px=プレビュー基準画像の長辺上限。上げるほど忠実・下げるほど毎入力が軽い。元が 1600px 以下なら縮小せず等倍 (拡大はしない) debounce 23
  15. 間引きと採否 — 非同期 UI の競合制御 要求A invoke ──────────────> 応答A 要求B

    invoke ────> 応答B effect は cleanup 済み → stale で破棄 現行 effect の要求 → 採用 で間引き、採否は effect クロージャの stale フラグが持つ cleanup が旧要求を無効化し、結果は render 時に path 照合 → 旧画像を出さない in-flight は止めない — プレビューは軽く副作用もないので捨てる方が単純 DEBOUNCE_MS = 130 =送信済みでまだ応答が返っていないリクエスト。処理が debounce の 130ms より長引くと要求が重なり、複数が同時に in-flight になる。 invoke は発行後に取り消 せず 側は最後まで走る = 受け取ってから捨てる in-flight Rust 24
  16. 単一実装 — Rust の 2 経路が blur_rgba に収束 preview export

    apply_stack_scaled(img, stack, scale) ┐ max(1, round(radius × scale)) ├→ blur_rgba(img, kind, r) apply_stack(img, stack) ┘ radius そのまま 上がプレビュー(縮小画像)、下が書き出し(フルサイズ)の経路 ブラーを掛けるコードは blur_rgba ただ 1 つ。2 経路の違いは「半径をスケール するか」だけ デコードも decode_rgba の共通経路 → EXIF 向きの正規化も両経路で一致する → ズレうる箇所が構造的に 1 行に閉じる(レビューで守り切れる粒度) 25
  17. 半径のスケール補正 preview_radius = max(1, round(radius × scale)) , 1600 scale

    = min (1, ) max(w, h) ​ 半径はピクセル単位の長さ → 画像を scale 倍に縮めれば、同じ見え方になる半 径も scale 倍 補正しないと縮小画像が過剰にぼける(前ページの 0.5% → 1.25%) PREVIEW_MAX_DIM = 1600 (長辺) 。元が 1600 以下なら scale = 1.0 で拡大はしな い 上式が効くのは正の半径のみ — radius = 0 は下限を適用せず 0 のまま(=恒 等) 元画像 scale UI radius 4000×3000 0.40 20 8 4000×3000 0.40 1 1 round preview_radius ( は 0 → 下限 1) 26
  18. スケール補正の限界 — プレビューだけ順序が逆 プレビューは縮小 → ブラー、揃えたい相手はブラー → 縮小(フル出力を画面で 見た姿) この

    2 つは可換でない → 一致は保証ではなく実用上の近似 なぜ厳密な「ブラー → 縮小」にしないか: 毎入力でフル解像度のブラーと縮小を やり直す → 画素数 6.25 倍(4000×3000) 。縮小を初回で済ませる今の順序が LRU(1) キャッ シュの前提 27
  19. 近似が残すズレ — 丸めと表示側の縮小 丸めの量子化: 4000px の画像で radius=1 は round(1 ×

    0.4) = 0 = 恒等になっ ていた → プレビュー素通し・出力はぼけるという WYSIWYG 破れ → 下限 1 で修正済み 下限で「ぼけの存在」は保証されるが、弱ブラー × 強縮小では過剰側に ±1 画素 相当の誤差が残る 表示側でもう一段縮む: CSS の max-width/max-height + object-fit: contain 残る誤差は実用上ほぼ使わない領域 → 問題化したら詰める(YAGNI) =寸法の上限を親( .canvas-viewport )に制限。 <img> は固有の縦横比を持つので、上限に当たると比を保ったまま縮む/object-fit: contain= 箱の大きさが決まった後、中の画像をどう収めるかの指定。比を保って収まる最大で描き、余りは余白 max-width / max-height: 100% 28
  20. 縮小ベースの 1エントリキャッシュ(LRU(1)) キー = (ResourceLocation, max_dim, fingerprint) = どの画像を・どこまで縮め たか・どの内容か

    値 = ブラー前の縮小画像 + scale(+原本の ICC。結果でないので設定変更でも ヒット) 1 件で足りる: プレビューは常に選択中の 1 枚=ミスは切替時と内容更新時だけ ミス時の実費 = read → decode → downscale(画素数比例、12MP で 300ms) → N 件の是非は切替の待ち時間次第。1 件 約 6〜10MB で有界(縦横比で変動) =大きい画像ほど得 → 毎入力で走るのは ブラー + PNG encode。それを速くする話が次章 LRU(1) =容量 1 の LRU。直近の 1 件だけ保持し、別のキーが来たら捨てる(容量 1 では追い出し戦略が退化し、実装は Option<PreviewBase> の上書きだけ) 29
  21. 4 章の要点 — 書くか合成するかで、核と格子の扱いは逆になる 同じ「隠す」でも、ボックスとモザイクは判断が逐一反転する ボックス(核で混ぜる) モザイク(格子で潰す) 作り方 型が合わず書く resize

    2 回の合成 O(n) の作り方 状態の再利用 出力の縮小で相殺 端の解き方 仕様として受け取る clamp を選んで解く 残る課題: モザイクの格子だけ WYSIWYG が届かない(プレビューと書き出しでセ ルがズレる) 核(カーネル)=出力 1 画素を決めるとき、周囲の画素に掛ける重みの表。形がぼけの質を決める/格子=入力に依らず位置だけで決まるセルの区切り。モザイクはこの中を 1 色に潰す 31
  22. 核と格子の前提 — 重みで混ぜるか、位置で潰すか ガウス: 中心ほど重い釣鐘型の加重平均 → 滑らかなぼけ ボックス: 重みが一律の単純平均 →

    O(n) 化の余地がある(n=画素数) モザイク: 核を持たずセル(1 辺 b 画素)単位で 1 色に潰す=区分一定 モザイクだけ平行移動不変でない — 格子に位相がある(章末で効く) 重み ガウス核 │ █ █ █ │ █ █ █ █ █ │ █ █ █ █ █ █ █ █ █ └──────────────────────→ x in : 1 2 4 7 8 6 3 1 2 5 8 6 out : 2 2 2 7 7 7 2 2 2 6 6 6 重み ボックス核 │ █ █ █ █ █ █ █ █ █ ← すべて同じ重み │ █ █ █ █ █ █ █ █ █ │ █ █ █ █ █ █ █ █ █ └──────────────────────→ x ← モザイクだけ核を持たない(格子で潰す) ←[--b=3--] セル内は同値。格子は入力に依らず固定 32
  23. 混ぜ方も潰し方も半径 1 本 — スライダーからの固定変換 r σ= , 2 ​

    d = 2r + 1, b=r+1 は radius(r)1 本。種別ごとの強度へ固定変換で接続する 変換は 1 関数に固定( radius_to_sigma / radius_to_block )— 変えるなら 1 行 radius=0 で imageproc は assert!(sigma > 0) で panic → 早期 return で恒等を返し、防御を境界 1 箇所に置く 効き幅は揃えていない(b = r + 1 < d = 2r + 1)=モザイクが弱い UI = の radius(ぼかす半径の画素数)/σ=ガウスの広がり幅(重みの約95%が ±2σ)。σ = r/2 は実効範囲 ±2σ をボックスの ±r に揃えた取り決めで等価変換ではない/d= ボックスが平均する窓幅(画素数)。奇数なのは窓の中心を 1 画素に定めるため(偶数だと半画素ずれる)/b=モザイクのセル 1 辺の画素数 r UI 33
  24. 固定変換の先で詰まる — ボックスは型が合わずライブラリを使え ない ガウス: imageproc::gaussian_blur_f32 に委譲 — ジェネリックで RgbaImage

    をそ のまま渡せる ボックス: imageproc::box_filter は GrayImage 専用 → アルゴリズムでなく型で 弾かれる image の blur / fast_blur もガウス系のみ。ボックス平均そのものは提供なし 「無い」の判定は 3 段階: ①そのまま使える ②型を合わせれば使える ③本当に無 い ボックスは②(分解4回+再合成)を経て③扱い → 「無い車輪」だけ作る ジェネリック=画素型を差し替えられる関数の書き方( <P: Pixel> )。 gaussian_blur_f32 は RGBA でも Gray でも呼べるが、 box_filter は引数の型が GrayImage に固定され ている 34
  25. 型が合わないので自前で書く — 素朴な 2D は O(n ⋅ r2 ) 各画素で

    d × d の窓を総和して平均 → 仕事量 = n × d2 半径2倍で4倍遅い: 12MP・r=20 → 窓 41²=1681 → 約200億サンプル/チャネル 磨いても消えない構造 → 窓の「形」に注目する(次ページ) 出力1画素ごとに窓 d×d を丸ごと読み直す(r=1, d=3 → 9 画素) ┌──┬──┬──┬──┬──┐ ┌──┬──┬──┬──┬──┐ │▓ │▓ │▓ │ │ │ │ │▓ │▓ │▓ │ │ • = 出力画素 │▓ │• │▓ │ │ │ → │ │▓ │• │▓ │ │ ▓ = 読む近傍(d² = 9) │▓ │▓ │▓ │ │ │ │ │▓ │▓ │▓ │ │ 1 画素進むと 9 画素を読み直し └──┴──┴──┴──┴──┘ └──┴──┴──┴──┴──┘ 記法=入力が大きくなったとき計算量が「どう増えるか」の形だけを比べる記法(定数倍は無視)。ここでは n=画素数、r=ブラー半径。O(n ⋅ r2 )=「画素数に比例、か つ半径の2乗に比例」 O 35
  26. 2D の窓は縦横に分離できる — d2 → 2d 重みが一律 → 1/d2 を

    1/d × 1/d の外積に分解できる(分離可能) 「d × d の平均」=「横 → 縦の 1D 平均」の2段。読みは 1681 → 82 実装も 水平 src→tmp / 垂直 tmp→out の 2 パス。ランク1核なら効く定石 2D 窓を一括(読み d² = 9) ┌───┬───┬───┐ │ ▓ │ ▓ │ ▓ │ │ ▓ │ • │ ▓ │ = │ ▓ │ ▓ │ ▓ │ └───┴───┴───┘ 水平パス src→tmp ▓ ─ • ─ ▓ ∘ 垂直パス tmp→out ▓ │ • │ ▓ 分離可能性(separability)=2D カーネルが「横1本 ⊗ 縦1本」の外積に分解できる性質。ボックス核なら Kbox = k ⊗ k, k = d1 [1, … , 1] で、d × d の読みが d + d に減る (分解できない例: 円形の窓) ​ ​ 36
  27. 分離の次は r を消す — running-sum で O(n) let mut sum:

    i64 = (-r..=r).map(sample).sum(); sum += sample(x + r) - sample(x - 1 - r); tmp[i] = ((sum + half) / d) as u8; // 初期和: 行・列の先頭で1回だけ O(r) // 差分更新: 入る1画素を足し、出る1画素を引く // 固定除数 d の整数四捨五入(half = d/2) 窓を1つ右へ → 総和は作り直さない。更新は d に依存せず約4更新/画素 u8 → i64 昇格(負の座標と桁あふれ)+ (sum+half)/d の四捨五入 整数の加減算は正確 → 移動和に誤差が蓄積しない(パス間の u8 丸めは別)。状態 は sum 1変数 ガウス不可(重みが位置で変わる)→ 分離止まり。ボックスだけ 1681 → 82 → 約 4 running-sum (移動和)=直前の窓の合計を使い回し、端の差分だけで次の合計を得る技法。総和の再計算 O(d) が O(1) になる 37
  28. running-sum の端 — ゼロ埋めは端を暗くする 方式 端の見え方 running-sum との相性 ゼロ埋め 端が黒ずむ(0

    が混ざる) ◦ そのまま使える 除数を実画素数に縮小 暗くならない × 端分岐+除数の再計算で単純さが崩れる ミラー(折り返し) clamp とほぼ同等 △ 添字の折り返し計算が複雑になるだけ エッジ複製(採用) 暗くならない ◦ clamp 1行で済む エッジ複製=画像の外側を最も近い端の画素が続いているとみなす境界規則 端を複製しても除数 d は縮めない → 窓に 0 が混ざらず端が暗くならない 境界の読み出しはクロージャ 1 つに閉じ込め、ループ本体は端を意識しない 一様画像は端まで不変の性質テスト1本で境界バグを捕まえる 38
  29. ボックスは端まで自前、モザイクは合成で済む に pixelate / mosaic は無い — ボックスと同じ「無い」 だがモザイクは既にあるもので書ける: resize

    を 2 回呼ぶだけ(依存追加ゼロ・ 実体 13 行) ボックス=書くしかない(型が合わない)/モザイク=合成で済む 実装方針: まず合成を探し、合成できない時だけ書く — 「無い」=「合成でも作 れない」 image / imageproc fn mosaic_rgba(img: &RgbaImage, block: u32) -> RgbaImage { if block <= 1 { return img.clone(); } // 恒等 let (w, h) = img.dimensions(); let small = resize(img, w.div_ceil(block), h.div_ceil(block), Triangle); // 縮小=平均 resize(&small, w, h, Nearest) // 拡大=複製 } 39
  30. 合成の中身 — 縮小 Triangle・拡大 Nearest の非対称 (三角)=重み 1 − ∣x∣

    の加重平均。混ぜる幅は縮小率ぶん伸びる Nearest(最近傍)=入力を 1画素だけ読み、値をそのまま使う(混ぜない) セル辺長は半径から決まる b = r + 1、セル数は ⌈w/b⌉ × ⌈h/b⌉(切り上げ= 端が半端でもセルを捨てない) 縮小で情報を捨て、拡大で戻さない — この非対称がモザイクの本体 Triangle 縮小 \ 拡大 Nearest(複製・採用) Triangle(補間) Triangle(平均・採用) モザイク(セルが立つ) ただのぼかし(セルが溶ける) Nearest(点サンプル) 隠蔽にならない(代表1画素が原寸で復活) 弱いぼかし 左下が事故: 縮小を Nearest にすると代表 1 画素が原寸で戻り、文字の芯が読める ⌈x⌉ (天井関数)=x 以上の最小の整数。コードでは u32::div_ceil が対応/block=コード上の引数名(本文の b)。UI の radius とは変換関数 1 つだけで接続する 40
  31. 縮小の Triangle は「厳密なブロック平均」ではない の ratio = 入力幅 ÷ 出力幅。カーネルはこの ratio

    倍に引き伸ばされる 幅 7 画素・b=3 なら 7 → 3 セルの縮小 → ratio = 7/3 ≈ 2.3=タップは ±2.3 隣の block まで届く → セル値は「block 内 b 画素の平均」ではなくテント加重平 均 厳密なブロック平均なら thumbnail が実在する — 型制約と整数比前提があり不 採用 resize pixel : 1 2 3 4 5 weight: 0.06 0.24 0.41 0.24 0.06 block :[------------][-------------------] sum : 0.29 0.71 ← 出力セル 1(自分の block = 画素 3,4,5) 中心は画素 3、±2.3 の外の 0 と 6 は重み 0 左=隣の block / 右=自分の block ← 約 29% が隣の block から来る タップ=1 出力を作るために読む入力画素とその本数(加重平均の項数)。Nearest は 1 タップ、Triangle は引き伸ばした幅のぶんだけ増える/ thumbnail = image にあるサム ネイル生成用の縮小関数。 resize と違い、ブロックを矩形のまま厳密に平均する 41
  32. 厳密でなくても O(n) — b が相殺する計算量の会計 会計の作法: コスト = 出力画素数 ×

    タップ数。縮小は出力 1/b2 ・タップ b2 倍で 相殺 素朴 2D は bn (2b)2 + n = 5n、分離すると 3n + 3n/b(b=21 で 3.1) 2 ​ パス 出力画素数 タップ コスト(総計) ① 縮小・垂直 w × h/b 2b 2n ② 縮小・水平 w/b × h/b 2b 2n/b ③ 拡大・垂直 w/b × h 1 n/b ④ 拡大・水平 w × h 1 n オーダーに r が残るかは「出力数がどう変わるか」だけで決まる 42
  33. 計算量が同じでも中身は逆 — 状態の再利用 vs 出力の縮小 窓の大きさ 1画素あたり・素朴 2D 1画素あたり・分離後 r

    を上げると オーダー(総計) 中間バッファ ボックス モザイク d = 2r + 1 ( ) 2d ≈ 4r(r=20: 82) r に比例して増える b=r+1 d2 ≈ 4r2 r=20: 1681 O(n ⋅ r) → running-sum u8 1 枚 = 48MB 固定 5 ( ) 減る(4.5 → 3 に漸近) O(n+n/r) = O(n)(1 ≤ r ≤ n で有界) f32 = 96MB(b=2)〜9.2MB(b=21) 3 + 3/(r + 1) r=20: 3.1 で O(n) 読み回数は近いが中身が違う: ボックスは整数の加減算、モザイクは f32 の乗加算 メモリは逆: モザイクは弱いほど重い(縮小率が小さいほど中間が大きい) モザイクは自前で書く方が速い(1画素あたり 2 対 3.1)— それでも合成=自前の コードを増やさず、端と正規化も任せられる 43
  34. 逆なのは端の扱いも — resize 任せとエッジ複製の対比 端専用の分岐は 1 つも無い — 切り上げでセル数を決め、 Nearest

    が幅を割り振 るだけ 7px を 3 セルに割ると 3/3/1 ではなく 2/3/2 — 中心座標で選ぶ結果、均等に再配 分される 端が暗くならないのも resize 任せ(重みを正規化)— ボックスがエッジ複製で 選んで解いた問題を、モザイクはライブラリの仕様として受け取っている 代償: image がサンプル位置を変えればセル境界も静かに変わる → 気づく手立て が次ページ out : 0 1 2 3 4 5 6 center: 0.21 0.64 1.07 1.50 1.93 2.36 2.79 floor : 0 0 1 1 1 2 2 cell :[----------][----------------][----------] w = 7 → セル 3(ratio = 3/7) ← 幅 2/3/2。3/3/1 にはならない 44
  35. resize 任せの代償 — golden でなく性質で縛る (期待画像との完全一致)は書けるが、 image の実装に過剰固定され依存 更新で落ちる 代わりに入力によらず成り立つ関係=性質だけを並べ、「壊れ方」を捕まえる網に

    する golden テスト radius_zero_is_identity mosaic_preserves_dimensions_on_non_multiples mosaic_is_piecewise_constant_on_exact_grid mosaic_bounds_distinct_colors_on_non_multiples mosaic_uniform_image_is_unchanged mosaic_preview_never_collapses_to_identity 何を固定するか radius 0 は恒等(3 種別共通) 寸法不変(7×5, b=3) 整数倍寸法ならセル内一定(8×6, b=2) 色数 ≤ セル数(境界の丸め実装に依存しない上界) 一様画像は不変(重みの正規化) 縮小率 0.1 でも素通しにならない 45
  36. 性質でも縛れないもの — b = r + 1 はスケールと可換でない round(rs) +

    1 ​ 実装(スケール → 変換) ​ = round(s(r + 1))   = ​ 順序を直した案 s(r + 1) ​ ​ 理想(整数でない) =プレビューの縮小率(3章の scale 。4000px → 1600px なら s = 0.4) プレビューだけ半径を rs にして丸めてから変換へ(書き出しは r のまま) ガウスの σ = r/2 は線形 → 丸めを除けばスケールと可換 モザイクの b = r + 1 は +1 のオフセット → 丸めを除いても可換でない しかも b は整数( u32 )→ 一致する理想 s(r + 1) はそもそも表現できず、順序 を直しても round の誤差が残る s 46
  37. 可換でないズレの実際 — 書き出しとプレビューでセル数が食い違 う export : 4000px / b =

    20+1 = 21 → ceil(4000/21) = 191 セル preview: 1600px / b = round(20*0.4)+1 = 9 → ceil(1600/9) = 178 セル ideal : 1600px / b = 0.4*(20+1) = 8.4 → ceil(1600/8.4) = 191 セル(書き出しと一致) round : 1600px / b = round(8.4) = 8 → ceil(1600/8) = 200 セル プレビューのセルが約 7% 大きく、格子の位相もずれる 理想の 8.4 なら一致するが b は整数 → 丸めた 8 では今度は 200 セルになる ぼかしのズレは滲みの差、モザイクのズレは数えられる格子の差 → 目につきやす い それでもプレビューが素通しになることは無い: 丸めた半径の下限が 1 → b = r + 1 ≥ 2 = セルは必ず 2 画素以上(b = 1 なら恒等=素通し) 性質テストが縛るのは素通しの回避だけ — 書き出しとの一致は縛れない 47
  38. ズレを詰める 4 案 — どれも別のものと引き換え 改良案 効果 代償 変換→block の順でスケール

    200 セル=ズレ 7%→5% 一致しない。種別ごとの分岐 プレビューも原寸で計算 ズレゼロ 画素数 6.25 倍。可視領域だけ評価する基盤 sb が整数になる縮小率 格子も位相も一致 寸法が半径依存=縮小キャッシュ無効 「等倍でだけ正確」と明示 実装コストゼロ WYSIWYG を UI 側に降ろす 既存ソフト 規約あり 既定で採用 例なし UI で採用 採っているのは現状(7% 大きい)— 隠す用途では見え方が伝われば足りる 「規約あり」=縮小レベルをフィルタに渡して追従させる(未追従はバグ扱い) 前例が無いのは 3 案目だけ — 縮小して計算するのは既知の天秤 48
  39. まとめ 技術選定: Tauri v2、ピクセル処理は Rust に一本化 — 自前はボックスだけ 2. アーキテクチャ:

    Rust=ピクセル/FS、Web=UI/状態 — 境界は IPC 1 本 3. WYSIWYG プレビュー: 書き出しと同一経路 — ズレはスケール補正に集約 4a. ボックス(書くしかない): 型が合わず自前 → 分離+running-sumで O(n ⋅ 1. r2 ) → O(n) 端は clamp を選んで解き、境界処理は 1 箇所に閉じる 4b. モザイク(合成で済む): resize 2 回・13 行 — 端はライブラリ仕様として受 け取る — 正しさは golden でなく性質で縛る/格子のズレは詰め切れず残る — 49