HAKUTAI Tech Notes

IT関連、数学のことなどを主として思いつきで色々書き溜めていきます

AngularとPaper.jsでデジタイザを作る1

散布図や折れ線グラフなどの画像しかない時に、そのグラフの値を数値化(デジタイズ)したい場合がある。大抵の場合、「その画像を読み込む→座標軸を合わせる→グラフの線などに沿って自分で点をプロットする」といった手順を踏んで数値化していくことになる。このような数値化処理を実現するものをデジタイザと呼ぶ。今回はAngularとPaper.jsを利用してグラフのデジタイザを作ることを最終目標とし、まず最初の一歩として画像を読み込んでキャンバスに表示する機能の実装についてまとめる。

開発環境

  • Angular 10.1.2
  • Angular CLI 10.1.2
  • Angular CDK 10.2.4
  • typescript 4.0.3
  • Node 14.11.0
  • paper 0.12.11
  • bootstrap

完成イメージ

  • 読み込みボタンをクリックしてファイル選択ウインドウを開く
  • 画像を読み込む

ディレクトリ構成

src/app/以下のディレクトリ構成は下記の通り。今回の説明で主に登場するのはdigitizer.component.htmldigitizer.component.tsになる。

app
├── app-routing.module.ts
├── app.component.html
├── app.component.scss
├── app.component.ts
├── app.module.ts
├── components
│   └── digitizer
│       ├── digitizer.component.html
│       ├── digitizer.component.scss
│       └── digitizer.component.ts
└── models

実装

画像を読み込む

画像を表示するためのcanvas要素、画像読み込み用のbuttoninput要素、画像保持用のimg要素が主な部品になるのでHTML側に記述していく。
まず、画像読み込みボタンから間接的に操作するinput要素は非表示にしておく。本来は読み込み可能な画像ファイルの種類を指定すべきであるが今回は特に気にせずaccept="image/*"にしている。 また、実際に画像を表示するのはcanvas要素なのでimg要素も非表示にしておく。キャンバスに画像を表示するためには、予め画像を読み込んでimg要素にセットしておく必要があるのでこのような形になる。
さらに、クラスファイルの方で@ViewChildを使ってcanvas要素とinput要素を取得したいので、参照変数としてそれぞれ#canvas#fileInputをつけておく。

<div class="flame d-flex flex-column mt-4">
    <div class="d-flex">
        <div class="col-6 canvas-wrapper ml-2">
            <div>
                <canvas class="m-0" #canvas></canvas>
            </div>
            <div class="d-flex image-configure mt-2">
                <div class="col-5 p-0">
                    <button mat-raised-button (click)="onClickFileInputButton()">画像を読み込む</button>
                </div>
            </div>
        </div>
    </div>
</div>
<!-- 画像読込み用 -->
<input type="file" style="display: none;" #fileInput accept="image/*" (change)="onChangeFileInput($event)">
<img id="image" style="display: none;" src="{{imgSrc}}">


一応スタイルファイルも。SCSSを利用している。

.flame {
  width: 1450px;
  overflow: auto;
}

.canvas-wrapper {
  canvas {
    border: solid 1px #979797;
    width: 700px;
    height: 500px;
    &:hover {
      cursor: crosshair;
    }
  }
  .outrange {
    color: #ff0000;
  }
}

.mat-raised-button {
  color: #ffffff;
  background-color: #299c33;
  outline: none;
}


次にコンポーネントのクラスファイルを記述していく。まずはじめに@ViewChildcanvas要素とinput要素のDOMを取得する。

onClickFileInputButton()

画像を読み込むボタンをクリックした時に呼び出されるメソッド。
this.fileInput.nativeElement.click()input要素をクリックするだけなのだが、既に画像が表示されている状態で再度画像ファイルを読み込もうとした時に上書きして良いかどうかを確認するメッセージを表示するようにした。

onChangeFileInput()

input要素が変更された時に呼び出されるメソッド。
まず、new Image()HTMLImageElementオブジェクトを生成しimageに格納する。また、画像が読み込まれた時点で発火するonloadのコールバック関数を設定しておく。(現時点では中身は空だが後でキャンバスに画像を表示する処理を記述していく)
次に、new FileReader()で生成されるFileReaderオブジェクトをfileReaderに格納し、$eventとして渡されたイベントオブジェクトに含まれるファイル情報をfileに格納する。
その後、fileReaderreadAsDataURL()でファイルオブジェクトfileの読み込みを開始する。読み込み完了後に発火するfileReader.onloadのコールバック関数内でimgSrcimgタグのsrc属性)とimage.srcImageオブジェクトのsrc)にそれぞれfileReader.resultを格納する。ただし、image.srcstring型である必要がありstring | ArrayBuffer型のfileReader.resultを直接格納することができないためString型でキャストしてやる必要がある。image.srcfileReader.resultが格納された時点で画像の読み込みが開始され、完了すると前述のimage.onloadが発火する。

import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
import * as paper from 'paper';
import { Raster } from 'paper';
import { view } from 'paper/dist/paper-full';

@Component({
  selector: 'app-digitizer',
  templateUrl: './digitizer.component.html',
  styleUrls: ['./digitizer.component.scss']
})
export class DigitizerComponent implements OnInit {
  @ViewChild('canvas', { static: true }) canvas: ElementRef<HTMLCanvasElement>;
  @ViewChild('fileInput', { static: false }) fileInput: ElementRef;

  file: File = null;
  imgSrc: string | ArrayBuffer = '';

  constructor() { }

  ngOnInit(): void {
    paper.setup(this.canvas.nativeElement);
  }

  onChangeFileInput(event): void {
    if (event.target.files.length === 0) {
      this.file = null;
      this.imgSrc = '';
      return;
    }

    const image = new Image();
    image.onload = () => {
        // キャンバスに画像を表示する処理(後述)
    };
    const fileReader = new FileReader();
    this.file = event.target.files[0];
    fileReader.readAsDataURL(this.file);
    fileReader.onload = () => {
      this.imgSrc = fileReader.result;
      image.src = fileReader.result as string;
    };
  }

  onClickFileInputButton(): void {
    if (!this.file || confirm('新しく画像を読み込みますか?\n前の画像やプロット状態などは保存されません。')) {
      this.fileInput.nativeElement.click();
    }
  }
}


キャンバスに画像を表示する

Imageオブジェクトの読み込みが完了した後に発火するimage.onloadのコールバック関数でsetImageToCanvas()を呼び出す。

setImageToCanvas()

画像表示のためのメソッド。
Paper.jsでは、画像はRasterオブジェクトとして扱っていく。new Raster('image')Rasterオブジェクトを新規作成することができ、引数には表示対象の画像データを保持しているimg要素のid(今回は'image')を指定する。 さらに、初期はキャンバスの中央に画像を表示したいので、生成したRasterオブジェクトの中心位置positionをキャンバスの中心位置に合わせる。view.centerはキャンバス(view)の中心座標のPointオブジェクトになるのでそれを指定すればよい。

  onChangeFileInput(event): void {
     // 省略
    const image = new Image();
    image.onload = () => {
        this.setImageToCanvas(); // 追加
    };
     // 省略
  }

    // 省略

  private setImageToCanvas(): void {
    // キャンバス上のオブジェクトを全てクリア
    paper.project.activeLayer.removeChildren();

    const raster = new Raster('image');
    // Rasterオブジェクトの中心をキャンバスの中心に合わせる
    raster.position = view.center;
  }


以上で、読み込んだ画像をキャンバスに表示することができる。

おわりに

今回はキャンバスに画像を表示するための基本的なことを書いた。次の記事では、読み込んだ画像の平行移動(パン)と拡大縮小(ズーム)機能について述べる。最初はこの記事の中で画像読み込み・表示と一緒にまとめてしまおうかと思ったが、意外と内容が重かったので分けることにした。


参考