jsで画像のトリミング(回転とか拡大とかも)

11/05/2022

個人開発サイト

https://wordstacks.nocebo.jp/

にツイッターとかにあるような
プロフィール画像調整できるような機能追加
ちょっと自前で実装してみようかなー

って軽い気持ちで始めたら
思ってた3倍。いや5倍ぐらい面倒くさい作業になってしまった。

画像処理?とかゲーム作ってるようなのがメインの人
からしたら大した実装じゃないんだろうけど、

まぁ意地で頑張ってなんとか形にできたので忘れない為にも記事にしてみる。

Cropper.js

まず、なんでもいいから
トリミングができればいいですって人は

Cropper.js

とか使った方がいいと思う
記事にしといてなんだけど、自前で実装はおすすめしない。

けどCropper.jsはスマホとかのジェスチャーでの
回転とかスケーリングは対応してない?と思うので
そのあたりも実装しないと、って方には多少参考になるかもしれないです。

実装サンプル(codesandbox)


リンターとかなんか色々でてるけど
面倒いので動けばヨシ。としてる。

以降は実装のちょっとした補足とか

全部説明しようとすると
また結構時間かかるので個人的なポイントだけ

React

React使ってます。
でもメインの実装部分は
素のjsなのでReact以外でも適宜調整すれば
流用できるとは思う。

コンポーネント

src/components/Form/
以下でコンポーネント部分実装している

Image以下

フォーム部分はこのあたり

src/components/Form/Image/Root.tsx

(async () => {
  const response = await fetch(url);
  const arrayBuffer = await response.arrayBuffer();
  const blob = new Blob([arrayBuffer], {
    type: "image/jpeg",
  });

  const reader: FileReader = await readerOnLoadend(blob);
  setFile({
    file: new File([blob], "name"),
    previewUrl: reader.result,
  });
})();

ここはDBに画像がある場合の想定で、
あった場合には
画像からblob?に変換してfileにデータセットしてプレビューできるようにしている。

src/components/Form/Image/Files.tsx

“ファイルを選択”の部分
ドラッグ・ドロップでもfile読めるようにしてる。

src/components/Form/Image/Preview.tsx

モーダルじゃない普通の画像のプレビュー部分

Edit以下

src/components/Form/Image/Edit/Root.tsx

モーダル部分+デフォのジェスチャー無効にしたり
エスケープでモーダル閉じたりとか

Adjust以下

Root.tsx

スケールとかオリジンとかのstateは
UIからも変更できるように
このコンポーネントから子に渡してる

modules.tsx

リサイズ周りの処理がゴチャゴチャしたので
reducerとかでまとめてみたけど
お作法的にあってるかは知らない

Main.tsx

マウスとかタッチとかのイベント周り拾ったりするのがメイン

const dataInit = () => {
  return {
    isDown: false,
    start: [new Point()], // 始点(ジェスチャー用に配列で)
    current: [new Point()], // 現在値(ジェスチャー用に配列で)
    diff: new Point(), //start - currentの差分
    temp: new Point(), //ダウン時の前回値保持
    tempScale: 1,
    rotMinScale: 1,

    // ジェスチャー用
    tempRotate: 0,
    tempDeg: 0
  };
};
export let data = dataInit();

このあたりでマウスとかジェスチャーの移動値とかを保持しているけど
Reactで管理するとリレンダーかかりまくってよくないっぽいので
普通の?って言っていいのかわからんけど変数で管理。

Img.tsx

実際に操作する画像要素。

const transform = (target: any, size: any) => {
  const x = target.currentPos.x * size.base; // 正規化した数値からpx指定に
  const y = target.currentPos.y * size.base;
  const originX = target.origin.x * 100; // パーセント指定に*100
  const originY = target.origin.y * 100;
  return {
    transform: `translate(${x}px, ${y}px) rotate(${target.rotate}deg) scale(${target.scale})`,
    transformOrigin: `${originX}% ${originY}%`
  };
};

ここで位置とか回転とかスケーリングとかここで
cssに変換&useRefで直接スタイル更新してる。

ちなみにここもcanvasでいいかって、試しにやってみたら妙に重くなった覚えがあるので
画像の方が処理早い?のかも。ただの予想だけど

Canvas.tsx

ここで編集してる画像の位置とか回転とかの値取得して
オフスクリーンのcanvasに描画->canvasのblobをfileにセットって感じの流れです。

ctx.save();
ctx.translate(reference.x, reference.y);
ctx.rotate(radian);
ctx.scale(target.scale, target.scale);
ctx.drawImage(img, 0, 0, drw, drh);
ctx.restore();

このあたりで実際に描画してるけど
ctxの説明は canvas 描画 とかで調べれば色々出てくるので割愛。

Debug以下

はデバッグ用で非表示になってるけど、
display: none;
消せば色々値を表示したり頂点座標表示したりできるので
もしよければ試してください。
自分用なのでゴチャゴチャだけど。

utils以下

あとでutils以下でクラスとか関数とかまとめてるので補足。

point.ts

pointクラス、vectorクラスの方が命名的にはあってる?かもしれない。しらんけど。
距離とったり。補完点とったりで使う。

functions.tsx

ジェスチャーとかマウス関連の関数が多い、
ジェスチャーの座標を配列でとったり。
ジェスチャーから回転とかスケール値計算したりしてる。

あと他拾い物のコピペだけど
useAnimationFrameって
tick処理用のカスタムフックとかもここに書いてる。

RectTarget.tsx

ここがメインの実装部分かと思っている。

rotateMinScale

  public rotateMinScale(size: any): number {
    const radian = this.radian();
    let rot_w =
      size.viewH * Math.abs(Math.sin(radian)) +
      size.viewW * Math.abs(Math.cos(radian));
    let rot_h =
      size.viewH * Math.abs(Math.cos(radian)) +
      size.viewW * Math.abs(Math.sin(radian));
    // 背景画像かつ横長画像の場合の考慮
    if (this.isBg && this.sizeRatio.x > this.sizeRatio.y) {
      rot_w = rot_w * (1 / this.sizeRatio.x);
      rot_h = rot_h * (1 / this.sizeRatio.y);
    }
    this.minScale = Math.max(rot_w, rot_h);
    return this.minScale;
  }

回転するとエリアからはみ出す部分が出てくるので
回転によって最小スケール計算して
最小値以下にはスケーリングできないようにしている。

なんかちょっとスマートじゃない気もするけど
縦長だったり横長だったりの場合も対応するようにこんな感じになった。

diagonal

  private diagonal() {
    return {
      length: Math.sqrt(
        Math.pow(this.sizeRatio.x, 2) + Math.pow(this.sizeRatio.y, 2)
      ),
      radian: Math.atan(this.sizeRatio.y / this.sizeRatio.x),
      angle: Math.atan(this.sizeRatio.y / this.sizeRatio.x) * (180 / Math.PI)
    };
  }

対角線求めるやつです。
この後の編集する画像(矩形)の頂点座標求めたりに使ってます。

vertex

  // 矩形の各頂点を返す
  public vertex() {
    const dlength = this.diagonal().length * 0.5; //矩形対角線の長さの半分(中心点からの距離)
    const dradian = this.diagonal().radian; //矩形対角線のラジアン
    //----------------------------
    // 矩形中心を基準点とした頂点(左上から時計回り)の初期値
    let vertices = [
      new Point(
        Math.cos(Math.PI + dradian) * dlength,
        Math.sin(Math.PI + dradian) * dlength
      ),
      new Point(Math.cos(-dradian) * dlength, Math.sin(-dradian) * dlength),
      new Point(Math.cos(dradian) * dlength, Math.sin(dradian) * dlength),
      new Point(
        Math.cos(Math.PI - dradian) * dlength,
        Math.sin(Math.PI - dradian) * dlength
      ),
    ];
    //----------------------------
    // アフィン変換で回転・拡大・平行移動する
    // まとめて合成した方がいいんだろうけど一旦順にやる
    //----------------------------
    // 要素センターから左上(0,0)基準に移動
    vertices = vertices.map((p) => {
      return new Point(p.x + 0.5, p.y + 0.5);
    });
    // アフィン変換の基準点(cssのoriginは下にプラスなので注意)
    const reference = new Point(+this.origin.x, +this.origin.y);
    // 拡大縮小
    vertices = vertices.map((p) => {
      return new Point(
        (p.x - reference.x) * this.scale + reference.x,
        (p.y - reference.y) * this.scale + reference.y
      );
    });
    // 回転反映
    const radian = this.rotate * (Math.PI / 180);
    vertices = vertices.map((p) => {
      return new Point(
        (p.x - reference.x) * Math.cos(radian) +
          (p.y - reference.y) * -Math.sin(radian) +
          reference.x,
        (p.x - reference.x) * Math.sin(radian) +
          (p.y - reference.y) * Math.cos(radian) +
          reference.y
      );
    });
    // 移動値反映
    vertices = vertices.map((p) => {
      return new Point(p.x + this.targetPos.x, p.y + this.targetPos.y);
    });
    // イプシロン見にくいのでデバッグ用丸め込み
    vertices = vertices.map((p) => {
      return p.rounded();
    });
    return vertices;
  }

矩形の頂点座標求めるやつ、vertexっていいつつvertices返すのがなんか気持ち悪いかもだけどいいや。

アフィン変換

アフィン変換って存在を知らず
最初はどう計算すれば、というか、どう考えれば良いのか、
だけでかなりネットの海を彷徨ったので
これの存知ってるだけでだいぶ違うと思う。
昔の自分にアフィン変換使えばいいよって教えてあげたい。

昔の記事だけどこれもアフィン変換使った方がわかりやすいかったかも?と思っている。

アフィン変換は使ってないけど
近しい実装でちょっと詳しく説明とかもしているので
他の部分でも良く分からない箇所とかある場合は
こっちも参考にしてもらってもいいかもしれない。

inside 衝突(包含)判定

  // エリア内に収める
  public inside(): any {
    // 再帰で移動繰り返してループしちゃった場合は元の場所に
    if (this.insideLoop == 0) {
      this.tempPos = new Point(this.currentPos.x, this.currentPos.y);
    }
    if (this.insideLoop > 100) {
      this.insideLoop = 0;
      this.targetPos.x = this.tempPos.x;
      this.targetPos.y = this.tempPos.y;
      return;
    }
    this.insideLoop++;

    const radian = this.radian();
    const vertex = this.vertex();
    const frame = this.viewArea();

    // 衝突(包含)判定
    // https://yttm-work.jp/collision/collision_0007.html#head_line_03
    // centerの座標を原点として逆回転ってなってるけど別に原点(0,0)でいい気がする
    const vertex_rot = vertex.map((p) => {
      return new Point(
        p.x * Math.cos(-radian) + p.y * -Math.sin(-radian),
        p.x * Math.sin(-radian) + p.y * Math.cos(-radian)
      );
    });
    // 表示領域も逆回転
    const frame_rot = frame.map((p) => {
      return new Point(
        p.x * Math.cos(-radian) + p.y * -Math.sin(-radian),
        p.x * Math.sin(-radian) + p.y * Math.cos(-radian)
      );
    });

    const eps = Number.EPSILON;
    // 回転した座標で当たり(包含)判定
    // [0]:左上 [1]:右上 [2]:右下 [3]:左下
    const frame_bool = frame_rot.map((frame, index) => {
      let bool = true;
      const topLeft = vertex_rot[0];
      if (frame.x + eps < topLeft.x) bool = false; // 左
      if (frame.y + eps < topLeft.y) bool = false; // 上
      const bottomRight = vertex_rot[2];
      if (frame.x - eps > bottomRight.x) bool = false; // 右
      if (frame.y - eps > bottomRight.y) bool = false; // 下
      return bool;
    });

    const isInside =
      frame_bool[0] && frame_bool[1] && frame_bool[2] && frame_bool[3];

    // 判定自体はここまででOK
    this.isInside = isInside;

    // エリア外に出た場合はinsideMoveから再帰
    if (!isInside) {
      this.insideMove(vertex_rot, frame_rot);
    } else {
      this.insideLoop = 0;
    }

    return this.isInside;
  }

エリア内しか移動できないようにするやつ。
これもアフィン変換の応用?になると思う。

insideMove

  // ここではみ出した分移動制御用に数値取って収める
  private insideMove(vertex_rot: any, frame_rot: any): void {
    // console.log("insideMove");
    const radian = this.radian();
    // ゼロだと無限に再帰しちゃう時があるのでイプシロンで
    const eps = Number.EPSILON;
    // 差分
    const frame_diff: any = frame_rot.map((frame: any, index: any) => {
      const diff: any = {};
      const topLeft = vertex_rot[0];
      const bottomRight = vertex_rot[2];

      // 各辺との距離
      if (topLeft.x - frame.x > eps) {
        diff.left = topLeft.x - frame.x; // 左
      }
      if (topLeft.y - frame.y > eps) {
        diff.top = topLeft.y - frame.y; // 上
      }
      if (bottomRight.x - frame.x < -eps) {
        diff.right = bottomRight.x - frame.x; // 右
      }
      if (bottomRight.y - frame.y < -eps) {
        diff.bottom = bottomRight.y - frame.y; // 下
      }

      return diff;
    });

    // mapはbreakができないのでentries forで
    for (const [index, _frame] of Object.entries(frame_diff)) {
      const frame: any = _frame;
      if (frame.left) {
        const len = frame.left;
        const add = new Point(len * Math.cos(radian), len * Math.sin(radian));
        this.targetPos.x = this.targetPos.x - add.x;
        this.targetPos.y = this.targetPos.y - add.y;
        this.inside();
        break;
      }
      if (frame.right) {
        const len = frame.right;
        const add = new Point(len * Math.cos(radian), len * Math.sin(radian));
        this.targetPos.x = this.targetPos.x - add.x;
        this.targetPos.y = this.targetPos.y - add.y;
        this.inside();
        break;
      }
      if (frame.top) {
        const len = frame.top;
        const add = new Point(len * Math.sin(radian), len * Math.cos(radian));
        this.targetPos.x = this.targetPos.x + add.x;
        this.targetPos.y = this.targetPos.y - add.y;
        this.inside();
        break;
      }
      if (frame.bottom) {
        const len = frame.bottom;
        const add = new Point(len * Math.sin(radian), len * Math.cos(radian));
        this.targetPos.x = this.targetPos.x + add.x;
        this.targetPos.y = this.targetPos.y - add.y;
        this.inside();
        break;
      }
    }
  }

はみ出た場合にはみ出した(各辺との距離)値をaddする。
+addした分で他の辺でみ出す可能性があるので
再帰でinside呼び出す。って感じです。

単純にx,yにaddすると
回転してた場合におかしくなるので
各辺毎に上記みたいな処理になった。

以上です

クライアント仕事の合間縫ってで全く触れてない期間もあったりだけど
gitの履歴見ると一年前ぐらいからやってたみたい、
掛かった時間との費用対効果とかは考えたくない感じである。

もし実装のご依頼なんかございましたら連絡ください。

javascript, web

Posted by admin