2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

React と Amazon Rekognition を用いたサーバレスウェブアプリケーション構築

Last updated at Posted at 2021-03-06

ESLint との格闘の末

はじめに

過去に作成した画像分析サーバレス Web アプリのフロントエンドを Vue から React + TypeScript + Material-UI の組み合わせに変更

ソースは下記 GitHub に公開

アプリ概要

  1. ブラウザ上で画像を選択、その画像のファイル名表示とプレビュー
  2. API 経由で画像をアップロード
  3. アップロードした画像の識別結果を表示

react-demo.gif

構成図

フロントエンドは React, バックエンドは Chalice で構築

arch.png

バックエンド

  • Chalice を用いて API Gateway エンドポイントを作成し、Lambda から Rekognition, Translate を使用するバックエンドを構築
  • 詳細はまずやってみる機械学習 ~AWS SAGEMAKER/REKOGNITIONとVUEで作る画像判定WEBアプリケーション1を参照

フロントエンド

開発環境の構築からアプリの構築まで

React 開発環境

  • 本アプリは、開発環境のテンプレートとして下記のリンク先の04-advancedを拝借2
  • Linter の設定など全部済んでいるため、至れり尽せりで非常にオススメ

Material-UI

  • 今回は v5.0.0 (プレビュー版) を利用
  • インストール方法は公式を参照
package.json
"dependencies": {
  "@material-ui/core": "^5.0.0-alpha.25",
  "@material-ui/icons": "^5.0.0-alpha.26",
  "@material-ui/lab": "5.0.0-alpha.25",
  (略)
}

Atomic Design

  • パーツ・コンポーネント単位で定義していく UI デザイン手法の Atomic Design で設計3
  • Template / Pages / Organisms / Molecules / Atoms の構成要素に分けて画面を設計
  • 詳細は 下記参照

Templates

  • 全体のレイアウト、テーマを設定
    • 赤枠 (header) : AppBarコンポーネントでタイトル部を作成
    • 青枠 (main) : アプリのメインで children にバインド
    • 緑枠 (footer) : Copyright用に作成

Screen Shot 2021-03-06 at 22.08.41.png

GenericTemplate.tsx
import React from 'react';
import CssBaseline from '@material-ui/core/CssBaseline';
import {
  createMuiTheme,
  createStyles,
  makeStyles,
  Theme,
  ThemeProvider,
} from '@material-ui/core/styles';
import { AppBar, Link, Typography } from '@material-ui/core';

const muiTheme = createMuiTheme({
  typography: {
    h5: {
      fontWeight: 800,
      fontFamily: ['sans-serif'].join(','),
    },
    fontFamily: [
      'Noto Sans JP',
      'Lato',
      '游ゴシック Medium',
      '游ゴシック体',
      'Yu Gothic Medium',
      'YuGothic',
      'ヒラギノ角ゴ ProN',
      'Hiragino Kaku Gothic ProN',
      'メイリオ',
      'Meiryo',
      'MS Pゴシック',
      'MS PGothic',
      'sans-serif',
    ].join(','),
  },
  palette: {
    mode: 'light', // dark is "dark mode"
  },
});

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    appbar: {
      padding: theme.spacing(2),
      Height: '200px',
    },
  }),
);

// Link => eslint を disable にする必要がある?
// 参考:https://next.material-ui.com/components/links/#main-content
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/anchor-is-valid */
const Copyright = () => (
  <Typography variant="body2" align="center">
    {'Copyright © '}
    {new Date().getFullYear()}
    <Link href="https://github.com/takanassyi/react-and-rekognition">
      {' '}
      takanassyi{' '}
    </Link>
    All rights reserved.
  </Typography>
);

export interface GenericTemplateProps {
  children: React.ReactNode;
}

const GenericTemplate: React.FC<GenericTemplateProps> = ({ children }) => {
  const classes = useStyles();

  return (
    <ThemeProvider theme={muiTheme}>
      <CssBaseline />
      <div>
        <header>
          <AppBar position="static" className={classes.appbar}>
            <Typography variant="h6">
              Image Classification Example (React Frontend App Ver.)
            </Typography>
          </AppBar>
        </header>
      </div>
      <div>
        <main>{children}</main>
      </div>
      <div>
        <footer>
          <Copyright />
        </footer>
      </div>
    </ThemeProvider>
  );
};
export default GenericTemplate;

Pages

  • Template からデザインを継承
  • Grid でレイアウト
  • Pages をトップに propsOrganisms, Molecules へデータと関数を伝播
  • http://<<YOUR ENDOPOINT URL>> に Chalice で構築したエンドポイントの URL を設定
  • axios で API 実行 (本来は別のソースに切り出すべき?)
Page.tsx
import React, { useState } from 'react';
import { Grid, Theme, createStyles, makeStyles } from '@material-ui/core';

import DisplayResult from 'Components/Organisms/DisplayResult';
import UploadImage from 'Components/Organisms/UploadImage';
import GenericTemplate from 'Components/Templates/GenericTemplate';

import axios from 'axios';
import { Result } from 'utils/utils';

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    grid: {
      padding: theme.spacing(2),
    },
  }),
);

const Page: React.FC = () => {
  const classes = useStyles();

  const [image, setImage] = useState<string | null | ArrayBuffer | undefined>(
    null,
  );
  const [fileName, setFileName] = useState<string>('');
  const [pending, setPending] = useState<boolean>(false);
  const [items, setItems] = useState<Result[]>([]);

  const getImage = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files === null) return;

    try {
      const reader = new FileReader();
      reader.readAsDataURL(e.target.files[0]);
      reader.onload = () => {
        setImage(reader.result);
        if (e.target.files === null) return;
        setFileName(e.target.files[0].name);
      };
    } catch {
      console.error('Error');
    }
  };

  // TODO:型を明確にしながら axios を使う
  // https://qiita.com/keyakko/items/ec536545d2faa9cabc84

  const fetchData = async () => {
    const config = {
      headers: {
        'content-type': 'application/octet-stream',
      },
    };
    const resultAxios = await axios.post<string>(
      'http://<<YOUR ENDOPOINT URL>>/api/rekognition',
      image,
      config,
    );

    const results: Result[] = [];
    resultAxios.data.split(',').map((text, id) => results.push({ id, text }));

    setItems(results);
    setPending(false);
  };

  const uploadImage = () => {
    if (typeof image !== 'string') {
      return;
    }
    setPending(true);
    void fetchData();
  };

  return (
    <GenericTemplate>
      <Grid container spacing={2} className={classes.grid}>
        <Grid item sm={6}>
          <UploadImage
            image={image}
            getImage={getImage}
            fileName={fileName}
            uploadImage={uploadImage}
            pending={pending}
          />
        </Grid>

        <Grid item sm={6}>
          <DisplayResult items={items} />
        </Grid>
      </Grid>
    </GenericTemplate>
  );
};
export default Page;

Organisms

  • Page を左右2つの領域に分割
    • 左側 (赤枠) : 画像の選択、アップロードに関する領域
    • 右側 (青枠) : 画像認識の結果を表示する領域

Screen Shot 2021-03-06 at 23.16.14.png

UploadImage.tsx
import React from 'react';
import { Grid, Typography } from '@material-ui/core';

import DisplayImage from 'Components/Molecules/DisplayImage';
import SelectImage from 'Components/Molecules/SelectImage';

type UploadImageProps = {
  image: string | null | ArrayBuffer | undefined;
  fileName: string;
  getImage: (event: React.ChangeEvent<HTMLInputElement>) => void;
  uploadImage: () => void;
  pending: boolean;
};
// ブレークポイントとGrid item/containerの解説
// https://blog.katsubemakito.net/react/react1st-28-materialui
// 12を超えると次の行に送られる
const UploadImage: React.FC<UploadImageProps> = (props: UploadImageProps) => {
  const { image, fileName, getImage, uploadImage, pending } = props;

  return (
    <Grid container spacing={2}>
      <Grid item sm={12}>
        <Typography variant="h5">Select Image file</Typography>
      </Grid>

      <Grid item sm={12}>
        <Grid container spacing={2} alignItems="flex-end">
          <SelectImage fileName={fileName} getImage={getImage} />
        </Grid>
      </Grid>

      <Grid item sm={12}>
        <Grid container spacing={2} alignItems="flex-end">
          <DisplayImage
            image={image}
            pending={pending}
            uploadImage={uploadImage}
          />
        </Grid>
      </Grid>
    </Grid>
  );
};

export default UploadImage;
DisplayResult.tsx
import React from 'react';
import {
  Grid,
  Paper,
  Typography,
  createStyles,
  makeStyles,
  Theme,
  // colors,
} from '@material-ui/core';
import { Result, ResultProps } from 'utils/utils';

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    paper: {
      width: '100%',
      padding: theme.spacing(1),
    },
  }),
);

const DisplayResult: React.FC<ResultProps> = (props: ResultProps) => {
  const classes = useStyles();

  const { items } = props;

  return (
    <Grid container spacing={2}>
      <Grid item sm={12}>
        <Typography variant="h5">Result - Rekognition</Typography>
      </Grid>
      <Grid item sm={12}>
        <Grid container>
          {items.map((result: Result) => (
            <Paper
              className={classes.paper}
              key={result.id}
              square
              variant="outlined"
            >
              <Typography>{result.text}</Typography>
            </Paper>
          ))}
        </Grid>
      </Grid>
    </Grid>
  );
};

export default DisplayResult;

Molecules

  • UploadImage を更に上下に分割
    • 上側(黄枠) : 画像の選択、ファイル名表示(SelectImage.tsx)
    • 下側(緑枠) : 画像のプレビュー、アップロードボタン表示(DisplayImage.tsx)
SelectImage.tsx
import React from 'react';
import { Button, Grid, Typography } from '@material-ui/core';

import { Image } from '@material-ui/icons';

type SelectImageProps = {
  fileName: string;
  getImage: (event: React.ChangeEvent<HTMLInputElement>) => void;
};

const SelectImage: React.FC<SelectImageProps> = (props: SelectImageProps) => {
  const { fileName, getImage } = props;

  return (
    <>
      <Grid item>
        <Button
          startIcon={<Image />}
          color="primary"
          variant="contained"
          component="label"
        >
          Select
          <input
            id="img"
            type="file"
            accept="image/*,.png,.jpg,.jpeg,.gif"
            hidden
            onChange={(e: React.ChangeEvent<HTMLInputElement>) => getImage(e)}
          />
        </Button>
      </Grid>
      <Grid item>
        <Typography>{fileName}</Typography>
      </Grid>
    </>
  );
};
export default SelectImage;
DisplayImage.tsx
import React from 'react';
import { createStyles, makeStyles, Grid } from '@material-ui/core';
import { Cloud } from '@material-ui/icons';
import LoadingButton from '@material-ui/lab/LoadingButton';

const useStyles = makeStyles(() =>
  createStyles({
    img: {
      maxWidth: '100%',
      height: 'auto',
    },
  }),
);

type DisplayImageProps = {
  image: string | null | ArrayBuffer | undefined;
  uploadImage: () => void;
  pending: boolean;
};

const DisplayImage: React.FC<DisplayImageProps> = (
  props: DisplayImageProps,
) => {
  const classes = useStyles();
  const { image, uploadImage, pending } = props;

  return (
    <>
      {typeof image === 'string' ? (
        <>
          <Grid item sm={12}>
            <img alt="detectimage" src={image} className={classes.img} />
          </Grid>
          <Grid item sm={12}>
            <LoadingButton
              pending={pending}
              variant="contained"
              startIcon={<Cloud />}
              onClick={uploadImage}
            >
              Upload
            </LoadingButton>
          </Grid>
        </>
      ) : (
        <></>
      )}
    </>
  );
};
export default DisplayImage;

Atoms

  • ラベル、ボタンなど最小単位を集めたもの
  • 今回は Material-UI のコンポーネントそのまま使用

utils

  • Rekognition の結果を格納するための型を定義
  • idDisplayResult.tsxmap で複数の要素を並べるときにユニークな key 指定をするために付与4

utils.ts
export type Result = {
  id: number;
  text: string;
};

export type ResultProps = {
  items: Result[];
};

おわりに

  • シンプルなアプリだが、フロントエンドとバックエンドを統合することで、それぞれの技術要素の理解につながった
  • シンプルが故、Atomic Design の適用は大袈裟だったかも
  • useState のみで実装しているため、無駄な再描画が多い懸念
    • 他のフックを利用して効率的な再描画が必要
  • axios の処理について型付けができていない
    • axios の Post 処理は切り出して utils へ切り出したほうが良い5
  1. 上記はSageMakerで推論モデルを生成して、そのエンドポイントも利用しているが、本 Web アプリでは Rekognition のみ利用

  2. 『りあクト!』で有名な大岡さんのリポジトリ

  3. React と相性が良いとされる

  4. ここが ES Lint と格闘した部分

  5. JavaScript の Promise がよくわかってないと切り出せない

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?