【JavaScript】画像をベジェ曲線に沿って曲げる【Canvas】

# やりたいこと
やりたいことは以下のような感じです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/163605/b8a5f0d1-21bc-991f-1147-9c16194fcbd1.png)

曲がりをベジェ曲線で定義し、始点終点の幅も指定できます。
※処理効率がよくないので、なおして使った方がよいかもしれません。(作りたてほやほや)

# サンプルプログラム
bezierImageを呼べばOKです。
描画するキャンバスのコンテキストと画像とベジェ曲線と線の幅を用意してください。
※プログラムを動かすには画像ファイルだけ用意してください。

```html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>bezier image</title>
<script>
document.addEventListener('DOMContentLoaded', () => {
    const canvas = document.querySelector('#my-canvas');
    const ctx = canvas.getContext('2d', { willReadFrequently: true });
    const points = [
        { x: 100, y: 600, },
        { x: 200, y: 100, },
        { x: 300, y: 100, },
        { x: 400, y: 600, },
    ];
    const startLineWidth = 60;
    const endLineWidth = 100;
    const img = new Image();
	img.onload = () => { bezierImage(ctx, img, points, startLineWidth, endLineWidth); };
	img.src = 'images/america.png';
});

/**
 * ベジェ曲線に沿って画像を描画する
 * @param {CanvasRenderingContext2D} ctx キャンバスのコンテキスト
 * @param {Image} img 画像
 * @param {Araay<{ x: number, y: number}>} 制御点の配列
 * @param {number} startLineWidth 始点の線の幅
 * @param {number} endLineWidth 終点の線の幅
 * @returns {void} なし
 */
function bezierImage(ctx, img, points, startLineWidth, endLineWidth) {
    const imgCanvas = document.createElement('canvas');
    imgCanvas.width = img.width;
    imgCanvas.height = img.height;
    const imgCtx = imgCanvas.getContext('2d', { willReadFrequently: true });
    imgCtx.drawImage(img, 0, 0);
    const imgImageData = imgCtx.getImageData(0, 0, imgCtx.canvas.width, imgCtx.canvas.height);

    // ベクトルを求める
    const vectors = getVectors(points);

    // 全体の長さを求める
    const wholeLength = getWholeLength(points, 1000);
    const div = Math.round(wholeLength * 4);

    ctx.reset();

    let curLength = 0;
    for(let i = 0; i <= div; i += 1) {
        const t = i / div;        
        const pos = {
            x: getBezierPoint(points, t, 'x'),
            y: getBezierPoint(points, t, 'y'),
        };
        if(i > 0) {
            const preT = (i - 1) / div;
            const prePos = {
                x: getBezierPoint(points, preT, 'x'),
                y: getBezierPoint(points, preT, 'y'),
            };
            const dist = distance(pos, prePos);
            curLength += dist;
        }

        // 接線のベクトルを求める
        let vector = {
            x: getFirstDerivative(vectors, t, 'x'),
            y: getFirstDerivative(vectors, t, 'y'),
        };
        // 単位ベクトル化する
        vector = unit(vector);
        // -90度回す
        const nrm = getNrm(vector);
        const curLineWidth = startLineWidth + (endLineWidth - startLineWidth) * curLength / wholeLength;
        const top = {
            x: pos.x + curLineWidth / 2 * nrm.x,
            y: pos.y + curLineWidth / 2 * nrm.y,
        };
        const bottom = {
            x: pos.x + curLineWidth / 2 * (-nrm.x),
            y: pos.y + curLineWidth / 2 * (-nrm.y),
        };

        ctx.save();    
        const yDiv = Math.round(curLineWidth * 4);
        for(let j = 0; j <= yDiv; j += 1) {
            const rate = j / yDiv;
            // intPosの色を画像から取得する
            const intPos = linearInterpolation(top, bottom, rate);
            const xRate = curLength / wholeLength;
            const yRate = rate;
            const color = getImageColor(imgImageData, xRate, yRate);
            ctx.fillStyle = `rgba(${color.r},${color.g},${color.b},${color.a / 255})`;
            ctx.fillRect(intPos.x, intPos.y, 1, 1);
        }
        ctx.restore();
    }  
    
    /**
     * 画像の色を取得する
     * @param {ImageData} imageData イメージデータ
     * @param {number} xRate xの割合
     * @param {number} yRate yの割合
     * @returns {{ r: number, g: number, b: number, a: number }} 色
     */
    function getImageColor(imageData, xRate, yRate) {
        const data = imageData.data;
        const width = imageData.width;
        const height = imageData.height;
        let x = Math.round(xRate * imageData.width);
        if(x < 0) { x = 0; }
        if(x >= width) { x = width - 1; }
        let y = Math.round(yRate * imageData.height);
        if(y < 0) { y = 0; }
        if(y >= height) { y = height - 1; }
        const i = y * width + x;
        return {
            r: data[i * 4 + 0],
            g: data[i * 4 + 1],
            b: data[i * 4 + 2],
            a: data[i * 4 + 3],
        };
    }

    /**
     * ベジェ曲線の長さを取得する
     * @param {Array<{ x: number, y: number }>} points 制御点の配列
     * @param {number} div 分割数
     * @returns {number} 長さ
     */
    function getWholeLength(points, div) {
        let wholeLength = 0;
        for(let i = 0; i < div; i += 1) {
            const t = i / div;
            const nextT = (i + 1) / div;
            const pos = {
                x: getBezierPoint(points, t, 'x'),
                y: getBezierPoint(points, t, 'y'),
            };
            const nextPos = {
                x: getBezierPoint(points, nextT, 'x'),
                y: getBezierPoint(points, nextT, 'y'),
            };
            const dist = distance(pos, nextPos);
            wholeLength += dist;
        }
        return wholeLength;
    }    

    /**
     * 制御点からベクトルを求める(単位ベクトル化される)
     * @param {Array<{ x: number, y: number }>} points 制御点の配列
     * @returns {Array<{ x: number, y: number }>} ベクトルの配列
     */
    function getVectors(points) {
        const vectors = [];
        for(let i = 0; i < points.length; i += 1) {
            if(i + 1 >= points.length) { continue; }
            const vector = { x: points[i + 1].x - points[i].x, y: points[i + 1].y - points[i].y, };
            vectors.push(unit(vector));
        }
        return vectors;
    }

    /**
     * ベジェ曲線の点を求める
     * @param {Array<{ x: number, y: number }>} points 制御点の配列
     * @param {number} t パラメータ
     * @param {string} prop プロパティ名
     * @returns {{ x: number, y: number, }} 点
     */
    function getBezierPoint(points, t, prop) {
        return (1 - t) ** 3 * points[0][prop] 
                + 3 * t * (1 - t) ** 2 * points[1][prop] 
                + 3 * t ** 2 * (1 - t) * points[2][prop] 
                + t ** 3 * points[3][prop];
    }

    /**
     * ベジェ曲線の1階微分を求める
     * @param {Array<{ x: number, y: number }>} vectors ベクトルの配列
     * @param {number} t パラメータ
     * @param {string} prop プロパティ名
     * @returns {{ x: number, y: number, }} 1階微分
     */
    function getFirstDerivative(vectors, t, prop) {
        const dr = (1 - t) ** 2 * vectors[0][prop]
                + 2 * t * (1 - t) * vectors[1][prop]
                + t ** 2 * vectors[2][prop];
        return dr * 3;
    }

    /**
     * 法線ベクトルを求める
     * @param {{ x: number, y: number, }} vec ベクトル
     * @returns {{ x: number, y: number, }} 法線ベクトル 
     */
    function getNrm(vec) {
        const unitVec = unit(vec);
        return { x: unitVec.y, y: -unitVec.x };
    }

    /**
     * 単位ベクトル化する
     * @param {{ x: number, y: number, }} vec ベクトル
     * @returns {{ x: number, y: number, }} 単位ベクトル 
     */
    function unit(vec) {
        const len = Math.sqrt(vec.x ** 2 + vec.y ** 2);
        return { x: vec.x / len, y: vec.y / len, };
    }

    /**
     * 位置ベクトルの距離を求める
     * @param {{ x: number, y: number, }} vec0 ベクトル
     * @param {{ x: number, y: number, }} vec1 ベクトル
     * @returns {number} 距離
     */
    function distance(vec0, vec1) {
        return Math.sqrt((vec0.x - vec1.x) ** 2 + (vec0.y - vec1.y) ** 2);
    }

    /**
	 * 線形補間する
	 * @param {{ x:number, y: number }} pos0 線分の始点
	 * @param {{ x:number, y: number }} pos1 線分の終点
	 * @param {number} t 補間パラメータ
	 * @return {{ x:number, y: number }} 線形補間された座標を返す
	 */
	function linearInterpolation(pos0, pos1, t) {
		return { 
			x: (1 - t) * pos0.x + t * pos1.x,
			y: (1 - t) * pos0.y + t * pos1.y
		};
	}
}

</script>
</head>
<body>
    <canvas id="my-canvas" width="800" height = "800"></canvas>
    <br><br>
</body>
</html>
```

# 修正履歴
2023/06/19: 線幅が倍になっていたのを修正

# 最後に
このロジックよくないです。
変換後の画素から変換前の画素への写像を用いて、色を取得するべきですが、
本ロジックは変換前の画素が変換後のどの画素へ移るかの写像を用いています。
自身のプログラムでは修正しましたが、かなりカスタマイズしているので一般的な関数ではなくなったので公開しません。
ということで、上記に書いたことを修正してお使いください。