【数学×JS】画像から輪郭を抽出してフーリエ変換&エピサイクルで描画するWebアプリの裏側
こんにちは。今回は、マウスで描いた絵やアップロードした画像の輪郭を抽出し、離散フーリエ変換(DFT)を用いてエピサイクル(回転する円の連鎖) で再現するWebアプリ「FouriTre」の実装の裏側について解説します。
本アプリはVanilla JS(フレームワークなし)で実装されており、ブラウザ上ですべての処理(エッジ検出、パスの最適化、フーリエ変換)を完結させています。特に、画像処理におけるCannyエッジ検出ライクなアプローチや、複素フーリエ変換といった数理的なトピックに焦点を当てて紹介します。
1. アプリの概要
「FouriTre」は、以下のようなステップで画像をエピサイクル・アニメーションに変換します。
- 画像のインポート: ユーザーが画像をアップロードする。
- エッジ検出: 画像から輪郭(エッジ)を抽出する。
- パスの連結・最適化: バラバラのエッジを、エピサイクルで描きやすいように一筆書きに近い形に連結する。
- 離散フーリエ変換(DFT): 抽出したパス(座標の点列)を複素数として扱い、周波数成分を抽出する。
- エピサイクル描画: 得られた周波数成分を元に、回転する円を連結させて軌跡を描く。
2. 画像からの輪郭抽出(Canny法ライクなアプローチ)
ブラウザ上で画像のエッジを検出するために、Canvas APIからピクセルデータを取得し、自前で画像処理を行っています。ここでは、有名なCannyエッジ検出アルゴリズムにインスパイアされた手法を実装しています。
2.1 グレースケール化と平滑化
まずはRGB値を輝度(Y)に変換し、ノイズを減らすために5x5の近似的なガウシアンブラーをかけます。
2.2 Sobelフィルタによる勾配計算
次に、Sobelフィルタを用いて水平方向($G_x$)と垂直方向($G_y$)の微分を近似計算し、勾配の強度と方向を求めます。
// 勾配の強度と方向の計算(抜粋)
var gx = -blur[idx - w - 1] + blur[idx - w + 1] - 2 * blur[idx - 1] + 2 * blur[idx + 1] - blur[idx + w - 1] + blur[idx + w + 1];
var gy = -blur[idx - w - 1] - 2 * blur[idx - w] - blur[idx - w + 1] + blur[idx + w - 1] + 2 * blur[idx + w] + blur[idx + w + 1];
var m = Math.sqrt(gx * gx + gy * gy); // 勾配強度
var angle = Math.atan2(gy, gx) * 180 / Math.PI; // 勾配方向
2.3 Non-Maximum Suppression (NMS: 非極大値抑制)
抽出されたエッジは太さを持っているため、エッジを1ピクセル幅に細線化します。勾配方向に対して、注目ピクセルの強度が周囲のピクセルよりも大きければ残し、そうでなければゼロにします。
2.4 Hysteresis Thresholding
エッジの途切れを防ぐため、強いエッジ(しきい値大)と弱いエッジ(しきい値小)の2つのしきい値を用います。強いエッジに隣接している弱いエッジのみを「真のエッジ」として採用します。
3. 抽出したエッジの連結(グローバル・グリーディ法)
画像から抽出されたエッジは、無数の短い線分の集まりになっています。これをそのままフーリエ変換すると、エピサイクルがそれぞれの線分を描くために画面内を飛び回り、非常に見栄えが悪くなります(長い直線の移動、ロングランが発生します)。
そこで、全体をできるだけ少ないストローク(理想は一筆書き)にまとめる必要があります。これは**巡回セールスマン問題(TSP)**に似たアプローチが必要です。
本アプリでは、グローバルなグリーディ法(Closest-Pair Merging) を実装しています。
// 端点同士が最も近いセグメントを結合していく処理(抜粋)
while (finalPaths.length > mxC && finalPaths.length > 1) {
var bestI = -1, bestJ = -1;
var minDistSq = Infinity;
var reverseI = false, reverseJ = false;
for (var i = 0; i < finalPaths.length; i++) {
var iHead = finalPaths[i][0];
var iTail = finalPaths[i][finalPaths[i].length - 1];
for (var j = i + 1; j < finalPaths.length; j++) {
var jHead = finalPaths[j][0];
var jTail = finalPaths[j][finalPaths[j].length - 1];
// 4パターンの接続距離を計算 (Tail-Head, Tail-Tail, Head-Head, Head-Tail)
var dTH = Math.pow(iTail.x - jHead.x, 2) + Math.pow(iTail.y - jHead.y, 2);
// ... (他のパターンの計算)
var minD = Math.min(dTH, dTT, dHH, dHT);
if (minD < minDistSq) {
minDistSq = minD;
bestI = i; bestJ = j;
// 反転フラグの設定
}
}
}
// 見つかった最適なペアを結合
if (bestI !== -1) {
var pathI = finalPaths[bestI];
var pathJ = finalPaths.splice(bestJ, 1)[0];
if (reverseI) pathI.reverse();
if (reverseJ) pathJ.reverse();
finalPaths[bestI] = pathI.concat(pathJ);
}
}
これにより、無駄なジャンプを最小限に抑え、自然な描画経路を生成します。また、閉じたループの場合は、始点が描画中にブレないように配列の並び替えを行っています。
4. 離散フーリエ変換 (DFT) による周波数解析
2Dの座標の点列を、複素数平面上の軌跡 $f(t) = x(t) + i y(t)$ とみなします。これを離散フーリエ変換(DFT)にかけることで、様々な周波数で回転する円(エピサイクル)の和として表現できます。
$$X_k = \frac{1}{N} \sum_{n=0}^{N-1} x_n \cdot e^{-i 2\pi k n / N}$$
function dft(pts) {
var N = pts.length, X = [], hN = Math.floor(N / 2);
// ナイキスト周波数まで計算
for (var k = -hN; k <= hN; k++) {
var re = 0, im = 0;
for (var n = 0; n < N; n++) {
var ph = (6.2832 * k * n) / N;
// オイラーの公式による複素数の掛け算
re += pts[n].x * Math.cos(ph) + pts[n].y * Math.sin(ph);
im += -pts[n].x * Math.sin(ph) + pts[n].y * Math.cos(ph);
}
re /= N; im /= N;
// 振幅(a)と位相(p)を求めて格納
X.push({ r: re, i: im, f: k, a: Math.sqrt(re * re + im * im), p: Math.atan2(im, re) });
}
// 振幅の大きい順(重要な円の順)にソート
X.sort(function (a, b) { return b.a - a.a });
return X;
}
得られた複素係数 $X_k$ の絶対値が「円の半径(振幅)」、偏角が「初期位相」、$k$ が「回転周波数」に対応します。
振幅が大きい順に円を連結していくことで、少ない数の円でも元の形を大まかに再現できる(JPEG圧縮と同じ原理)ようになります。
5. 描画時の工夫(ギブス現象の回避)
マウスなどで描いた「開いたパス(始点と終点が繋がっていない線)」をそのままフーリエ変換すると、始点と終点の間が無理やり直線で繋がれ、さらに不連続点となるためギブス現象(境界での激しい振動・ギザギザ)が発生します。
これを防ぐため、開いたパスの場合はプログラム内部で自動的に「来た道を戻る(往復パスにする)」処理を入れています。これにより、周期関数として連続性が保たれ、非常に滑らかなエピサイクルが生成されます。
おわりに
フーリエ変換や画像処理アルゴリズムは数式で見ると難しく感じますが、実際にJavaScriptで実装してビジュアライズしてみると、その強力さや美しさが直感的に理解できます。
Canvas APIでのピクセル操作から、Canny法ライクなエッジ検出、TSPライクな経路最適化、そして複素フーリエ変換まで、Webフロントエンドだけでこれだけのリッチな数学・情報系処理が完結するのは非常に面白いですね。
ぜひ、皆さんも自分なりのアルゴリズムを実装して遊んでみてください!

