Nuxt.jsとkonvaで台形補正

以下の記事で実現されているものをVue.jsでkonvaを使って実装してみたので、その内容をまとめます。

- [斜めに写った画像をCanvasで矩形に補正する](https://memo.sugyan.com/entry/2018/09/03/212712)

元記事様は実装については最低限しか触れられていなかったので、この記事では、ここのコードをまるっと使えば動くものを提供したいなと思います。
その他、canvasについてはズブのド素人だったので、以下の一連の記事を参考にさせていただきました。ありがとうございます！

- [VueでKonva.jsとcanvasを使ってお絵描き（その1）](https://qiita.com/Butterthon/items/2b3f4608a9f07c099c77)

## 台形補正

最初に出来上がったものをお見せします。

![台形補正.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273336/22b31124-1b5a-b8c7-953b-502809c96160.png)

左図の赤枠で囲われた部分を補正した結果を右図に出力しています。
参考元と同じく、リアルタイムで補正が行われるようにしており、なかなかよいパフォーマンスです。

![台形補正.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273336/0788a9e8-c39a-3fdf-d326-ccfe7ce8857a.gif)

## 環境

- Nuxt.js 2.14.6
- Konva 7.1.8
- glfx 0.0.4
 - canvasでの画像処理用ライブラリ

glfxは参考にさせていただいた記事にもありましたが、５年ほど更新がありません。そのため、TypeScriptには非対応です。TypeScriptで利用したいという方は、適宜、元記事様を参考にwrapperを定義してあげてください。

## 準備

konvaとglfxをインストールします。

```bash
npm install konva
npm install glfx
```

## template, style

早速実装内容を紹介します。まずは`<template>`と`<style>`ですが、ここは最低限。

```glfx.vue
<template>
  <div>
    <div>
      <div ref="container" class="canvas_inline">
        <canvas id="baseImg" width="600" height="600" ref="canvas" />
      </div>
      <div class="canvas_inline canvas_margin">
        <canvas ref="result" width="400" height="566" />
      </div>
    </div>
    <div>
      <span>{{ nowCorner }}</span>
    </div>
  </div>
</template>

<style scoped>
.canvas_inline {
  display: inline-block;
  vertical-align: top;
}

.canvas_margin {
  margin-left: 10px;
}
</style>
```

## script

処理の流れはざっくりと以下のような感じです。

- 起動時
 1. canvasに画像を表示
 2. `draggable`な4頂点を定義し、`dragmove`処理を追加
 2. 4頂点と4辺を描画
 3. 頂点の座標情報を取得し、補正を実施
 4. 補正画像を出力先`canvas`に描画

- 頂点操作時(`dragmove`)
 1. 座標の更新
 2. 座標情報を取得し、補正を実施
 3. 補正画像を更新

### 宣言

`class API`を使います。

```glfx.vue
<script>
import { Component, Vue, Watch } from 'nuxt-property-decorator';
import Konva from 'konva';

import * as fx from 'glfx';

@Component
export default class KonvaTop extends Vue {
  ...
}
</script>
```

### mounted

まずは、`mounted`にて`canvas`の初期設定を行い、補正に利用する画像を取得します。

```js

// data
width = 600;
height = 600;
stage = null;
canvas = null;
drawingLayer = null;
drawingScope = null;
imageObj = null;
backgroundLayer = null;
backgroundImageScope = null;

// 座標情報格納配列
coords = [
 [202, 123],
 [544, 103],
 [45, 432],
 [520, 498]
];

// 4辺に関係する関数
line = null;
corners = [];

// 現在の座標を表示する
nowCorner = '';

// perspective
perspectiveNubs = [];
baseCanvas = null;
image = null;
texture = null;
resCanvas = null;

mounted() {
  var container = this.$refs.container;
  this.stage = new Konva.Stage({
    container,
    width: this.width,
    height: this.height 
  });
  this.drawingLayer = new Konva.Layer();
  this.stage.add(this.drawingLayer);

  this.canvas = this.$refs.canvas;
  this.drawingScope = new Konva.Image({
    image: this.canvas,
    stroke: 'black'
  });
  this.drawingLayer.add(this.drawingScope);
  this.stage.draw();

  // 画像読み込み
  this.imageObj = new Image();
  this.imageObj.addEventListener('load', this.imageOnload);
  this.imageObj.src = require('../../static/IMAGE.JPG');
}
```

### 画像読み込み完了

画像読み込みが完了すると`imageOnload`関数が呼び出されます。
4辺の描画と補正画像の出力は、最初に定義したオブジェクトを更新していくため、初期起動時とその後の`dragmove`で処理が変わります

```js
imageOnload() {
  // 背景レイヤ
  this.backgroundLayer = new Konva.Layer()

  // 取得された画像データをImageオブジェクトに登録
  this.backgroundImageScope = new Konva.Image({
    image: this.imageObj,
    width: this.canvas.width,
    height: this.canvas.height
  })

  // 背景レイヤに背景イメージを追加
  this.backgroundLayer.add(this.backgroundImageScope)
  this.stage.add(this.backgroundLayer)

  // 背景イメージを最背面に移動。
  this.backgroundLayer.moveToBottom();

  // 頂点の描画
  this.drawCircle();

  // 4辺の初期描画
  this.initDrawLine();

  // 補正画像の初期表示
  this.initPerspectiveImage();
}
```

### 頂点の処理

初期起動時に`draggable`な4頂点のオブジェクトを生成し、`dragmove`のイベントを登録します。
こうすることで、頂点をドラッグで動かしてイベントを発火させることができます。

```js
drawCircle() {
  // 4つの頂点を1つずつ登録する
  for (let i = 0; i < 4; i++){
    this.corners[i] = new Konva.Circle({
      radius: 10,
      x: this.coords[i][0],
      y: this.coords[i][1],
      stroke: 'red',
      strokeWidth: 2,
      // こいつをtrueにすることでcanvas上で動かすことができる
      draggable: true
    });

    // 4つの頂点ごとにイベントを分ける。
    // もっとよい書き方があったら教えて下さい！
    switch(i) {
      case 0:
        this.corners[i].on('dragmove', this.moveCircle1);
        break;
      case 1:
        this.corners[i].on('dragmove', this.moveCircle2);
        break;
      case 2:
        this.corners[i].on('dragmove', this.moveCircle3);
        break;
      case 3:
        this.corners[i].on('dragmove', this.moveCircle4);
        break;
      default: break;
    }
    
    // 描画レイヤーに追加
    this.drawingLayer.add(this.corners[i]);
  }

  // canvasを更新
  this.stage.add(this.drawingLayer);

  // 座標情報を表示させる処理
  this.getNowCorner();
}
```

実際に頂点を動かすと、以下の処理が発火します。
座標情報を更新し、更新された座標情報をもとに、4辺を再描画し、補正画像を更新します。

```js
// それぞれどの頂点が動かされたかを識別するだけ
moveCircle1(node) {
  this.moveCircleBase(node, 0);
};

moveCircle2(node) {
  this.moveCircleBase(node, 1);
}

moveCircle3(node) {
  this.moveCircleBase(node, 2);
}

moveCircle4(node) {
  this.moveCircleBase(node, 3);
}

moveCircleBase(node, i) {
  // 座標情報を更新
  this.coords[i][0] = node.target.attrs.x;
  this.coords[i][1] = node.target.attrs.y;

  // 座標情報の表示を更新
  this.getNowCorner();

  // 4辺を再描画
  this.drawLine();

  // 補正画像を更新
  this.updatePerspectiveImage();
}
```

### 辺の処理

4辺は、補正エリアを定義する頂点を結んでいます。頂点をドラッグするときに同時に動かしたかったので、頂点のドラッグイベント時に表示を更新し、頂点の動きに追従できるようにしました。

もしもっとスマートなやり方があるようでしたら、ぜひ教えて下さい！

```js
// 初期化処理
initDrawLine() {
  this.line = new Konva.Line({
    // 左上→右上→右下→左下→左上の順番に線をつなぐ
    points: [
      this.coords[0][0], this.coords[0][1],
      this.coords[1][0], this.coords[1][1],
      this.coords[3][0], this.coords[3][1],
      this.coords[2][0], this.coords[2][1],
      this.coords[0][0], this.coords[0][1],
    ],
    stroke: 'red'
  });

  this.drawingLayer.add(this.line);
  this.stage.add(this.drawingLayer);
}

drawLine() {
  // すでに定義したLineオブジェクトの座標情報を更新
  this.line.attrs.points = [
    this.coords[0][0], this.coords[0][1],
    this.coords[1][0], this.coords[1][1],
    this.coords[3][0], this.coords[3][1],
    this.coords[2][0], this.coords[2][1],
    this.coords[0][0], this.coords[0][1],
  ];

  this.drawingLayer.add(this.line);
  this.stage.add(this.drawingLayer);
}
```

### 台形補正

ここが今回のキモです。
glfxライブラリは、`Image`要素から`texture`を取得し、その`texture`情報に画像処理をかけることができます。
台形補正を行うには`perspective(before, after)`というAPIを利用します。
`before`は補正前の4頂点の情報を`[ax, ay, bx, by, cx, cy, dx, dy]`と8つの要素の行列として格納し、
`after`には補正後の4頂点の情報を同じ形式でセットします。
今回は、`after`として`400:566`というA版、B版の割合に調整した画像を出力したかったので、

```js
const after = [ 0, 0, 400, 0, 0, 566, 400, 566];
```

こんな感じで`after`をセットしています。

また、別canvasに補正結果を出力するため、補正後の`canvas`オブジェクトから`getPixelArray()`で`Uint8Array`を取り出しています。
この取り出した値で`ImageData`を作り、出力先`canvas`要素に埋め込んでいます。

```js
// canvas,texture,出力先canvasを定義しておく
initPerspectiveImage() {
  try {
    this.baseCanvas = fx.canvas();
  } catch (e) {
    alert(e);
    return;
  }

  this.image = this.backgroundLayer.canvas._canvas;
  this.texture = this.baseCanvas.texture(this.image);
  this.resCanvas = this.$refs.result.getContext('2d');

  // 最初の補正情報
  this.updatePerspectiveImage();

}

updatePerspectiveImage() {
  // beforeの座標情報を設定
  this.setPerspectiveNubs();
  const before = this.perspectiveNubs;

  const after = [ 0, 0, 400, 0, 0, 566, 400, 566];
  this.baseCanvas
    .draw(this.texture)
    .perspective(before, after)
    .update()

  // ImageDataを取得して、別canvasに描画
  const data = this.baseCanvas.getPixelArray();
  const img = new ImageData(new Uint8ClampedArray(data), this.width*2, this.height*2);
  this.resCanvas.putImageData(img, 0, 0);
}

setPerspectiveNubs() {
  this.perspectiveNubs = [
    this.coords[0][0] * 2, this.coords[0][1] * 2,
    this.coords[1][0] * 2, this.coords[1][1] * 2,
    this.coords[2][0] * 2, this.coords[2][1] * 2,
    this.coords[3][0] * 2, this.coords[3][1] * 2,
  ];
}
```

## 最後に

- リポジトリは[こちら](https://github.com/yason0319/perspective-glfx)
- 四隅を検出するデータセットがあれば、画像読み込み時に頂点推定とかできそうですよね...
- TypeScriptでリファクタリングもしたいなあ