본문 바로가기
CLOUD/AWS

[Amplifyphoto] AI 추가 및 검색

by Rainbound-IT 2021. 9. 6.
반응형

AMAZON REKOGNITION 통합

콘텐츠에 대한 설명으로 수동으로 태그를 지정하지 않고도 이미지를 찾을 수 있다면 좋을 것입니다. 다행히도 이 기능을 추가하는 것은 Amazon Rekognition  덕분에 매우 쉽습니다. 우리는 사용할 수 있습니다DetectLabels API– 사진을 제공하면 이미지에 대한 적절한 레이블 목록으로 응답합니다.

 

Amazon Rekognition의 DetectLabels 빠른 요약

입력 이미지를 base64로 인코딩된 이미지 바이트로 전달하거나 Amazon S3 버킷의 이미지에 대한 참조로 전달합니다. AWS CLI를 사용하여 Amazon Rekognition 작업을 호출하는 경우 이미지 바이트 전달은 지원되지 않습니다. 이미지는 PNG 또는 JPEG 형식의 파일이어야 합니다.

각 객체, 장면 및 개념에 대해 API는 하나 이상의 레이블을 반환합니다. 각 레이블은 개체 이름과 이미지에 개체가 포함되어 있다는 신뢰도를 제공합니다. 예를 들어 입력 이미지에 등대, 바다, 바위가 있다고 가정합니다. 응답에는 각 개체에 대해 하나씩 3개의 레이블이 모두 포함됩니다.

{이름: 등대, Confidence: 98.4629}
{이름: 바위, Confidence: 79.2097}
{이름: 바다, Confidence: 75.061}

 

 

S3 Trigger Lambda 함수에 Amazon Rekognition 통합을 추가해 보겠습니다.

 

photoalbums/amplify/backend/function/S3Triggerxxxxxxx/src/index.js

더보기
/* Amplify Params - DO NOT EDIT
You can access the following resource attributes as environment variables from your Lambda function
var environment = process.env.ENV
var region = process.env.REGION
var apiPhotoalbumsGraphQLAPIIdOutput = process.env.API_PHOTOALBUMS_GRAPHQLAPIIDOUTPUT
var apiPhotoalbumsGraphQLAPIEndpointOutput = process.env.API_PHOTOALBUMS_GRAPHQLAPIENDPOINTOUTPUT

Amplify Params - DO NOT EDIT */// eslint-disable-next-line

require('es6-promise').polyfill();
require('isomorphic-fetch');
const AWS = require('aws-sdk');
const S3 = new AWS.S3({ signatureVersion: 'v4' });
const Rekognition = new AWS.Rekognition();
const AUTH_TYPE = require('aws-appsync').AUTH_TYPE;
const AWSAppSyncClient = require('aws-appsync').default;
const uuidv4 = require('uuid/v4');
const gql = require('graphql-tag');

/*
Note: Sharp requires native extensions to be installed in a way that is compatible
with Amazon Linux (in order to run successfully in a Lambda execution environment).

If you're not working in Cloud9, you can follow the instructions on http://sharp.pixelplumbing.com/en/stable/install/#aws-lambda how to install the module and native dependencies.
*/
const Sharp = require('sharp');

// We'll expect these environment variables to be defined when the Lambda function is deployed
const THUMBNAIL_WIDTH = parseInt(process.env.THUMBNAIL_WIDTH || 80, 10);
const THUMBNAIL_HEIGHT = parseInt(process.env.THUMBNAIL_HEIGHT || 80, 10);

let client = null

async function getLabelNames(bucketName, key) {
  let params = {
    Image: {
      S3Object: {
        Bucket: bucketName, 
        Name: key
      }
    }, 
    MaxLabels: 50, 
    MinConfidence: 70
  };
  const detectionResult = await Rekognition.detectLabels(params).promise();
  const labelNames = detectionResult.Labels.map((l) => l.Name.toLowerCase()); 
  return labelNames;
}

async function storePhotoInfo(item) {
  console.log('storePhotoItem', JSON.stringify(item))
  const createPhoto = gql`
    mutation CreatePhoto(
      $input: CreatePhotoInput!
      $condition: ModelPhotoConditionInput
    ) {
      createPhoto(input: $input, condition: $condition) {
        id
        albumId
        owner
        bucket
        fullsize {
          key
          width
          height
        }
        thumbnail {
          key
          width
          height
        }
        album {
          id
          name
          owner
        }
      }
    }
  `;

  console.log('trying to createphoto with input', JSON.stringify(item))
	const result = await client.mutate({ 
      mutation: createPhoto,
      variables: { input: item },
      fetchPolicy: 'no-cache'
    })

  console.log('result', JSON.stringify(result))
  return result
  }

function thumbnailKey(keyPrefix, filename) {
	return `${keyPrefix}/resized/${filename}`;
}

function fullsizeKey(keyPrefix, filename) {
	return `${keyPrefix}/fullsize/${filename}`;
}

function makeThumbnail(photo) {
	return Sharp(photo).resize(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT).toBuffer();
}

async function resize(photoBody, bucketName, key) {
  const keyPrefix = key.substr(0, key.indexOf('/upload/'))
  const originalPhotoName = key.substr(key.lastIndexOf('/') + 1)
  const originalPhotoDimensions = await Sharp(photoBody).metadata();
  
  const thumbnail = await makeThumbnail(photoBody);

	await Promise.all([
		S3.putObject({
			Body: thumbnail,
			Bucket: bucketName,
			Key: thumbnailKey(keyPrefix, originalPhotoName),
		}).promise(),

		S3.copyObject({
			Bucket: bucketName,
			CopySource: bucketName + '/' + key,
			Key: fullsizeKey(keyPrefix, originalPhotoName),
		}).promise(),
	]);

	await S3.deleteObject({
		Bucket: bucketName,
		Key: key
	}).promise();

	return {
		photoId: originalPhotoName,
		
		thumbnail: {
			key: thumbnailKey(keyPrefix, originalPhotoName),
			width: THUMBNAIL_WIDTH,
			height: THUMBNAIL_HEIGHT
		},

		fullsize: {
			key: fullsizeKey(keyPrefix, originalPhotoName),
			width: originalPhotoDimensions.width,
			height: originalPhotoDimensions.height
		}
	};
};

async function processRecord(record) {
	const bucketName = record.s3.bucket.name;
  const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, " "));

  console.log('processRecord', JSON.stringify(record))

  if (record.eventName !== "ObjectCreated:Put") { console.log('Is not a new file'); return; }
  if (! key.includes('upload/')) { console.log('Does not look like an upload from user'); return; }

  const originalPhoto = await S3.getObject({ Bucket: bucketName, Key: key }).promise()
  
	const metadata = originalPhoto.Metadata
  console.log('metadata', JSON.stringify(metadata))
  console.log('resize')
	const sizes = await resize(originalPhoto.Body, bucketName, key);    
  console.log('sizes', JSON.stringify(sizes))
  const labelNames = await getLabelNames(bucketName, sizes.fullsize.key);
  console.log(labelNames, labelNames)
	const id = uuidv4();
	const item = {
		id: id,
		owner: metadata.owner,
		albumId: metadata.albumid,
    bucket: bucketName,
    labels: labelNames,
    thumbnail: {
      width: sizes.thumbnail.width,
      height: sizes.thumbnail.height, 
      key: sizes.thumbnail.key,
    },
    fullsize: {
      width: sizes.fullsize.width,
      height: sizes.fullsize.height,
      key: sizes.fullsize.key,
    }
  }

  console.log(JSON.stringify(metadata), JSON.stringify(sizes), JSON.stringify(item))
	await storePhotoInfo(item);
}


exports.handler = async (event, context, callback) => {
  console.log('Received S3 event:', JSON.stringify(event, null, 2));

  client = new AWSAppSyncClient({
    url: process.env.API_PHOTOALBUMS_GRAPHQLAPIENDPOINTOUTPUT,
    region: process.env.REGION,
    auth: {
      type: AUTH_TYPE.AWS_IAM,
      credentials: AWS.config.credentials
    },
    disableOffline: true
  });
 
	try {
		event.Records.forEach(processRecord);
		callback(null, { status: 'Photo Processed' });
	}
	catch (err) {
		console.error(err);
		callback(err);
	}
};

 

변경한 사항

  • Amazon Rekognition API와 상호 작용할 AWS.Rekognition 인스턴스 생성
  • Rekognition.detectLabels 를 사용 하여 S3에서 주어진 사진에 대한 적절한 레이블 목록을 반환하는 getLabelNames 함수를 추가했습니다.
  • getLabelNames 함수를 사용하여 사진의 레이블을 가져오고 DynamoDB에 유지되는 항목 레코드에 포함하도록 processRecord 함수를 업데이트했습니다.

사진 프로세서 코드는 이제 Amazon Rekognition의 detectLabels API를 사용합니다. 그러나 이전 섹션에서 이 작업에 대한 권한을 이미 추가했으므로 CloudFormation 템플릿을 다시 업데이트할 필요가 없습니다.

 

 

 

이제 lambda 함수를 amplify push로 배포합니다. 

 

배포가 완료된 후, S3 트리거 기능은 CreatePhoto mutation을 발생시킬 때 새 속성으로 레이블을 삽입할 준비가 되지만 API는 아직 입력에 이 필드를 허용하지 않습니다.  밑에서 바로 해결할 것입니다.

 

 

 

 

 

 

 

사진을 검색 가능하게 만들기

GraphQL 스키마 업데이트

이제 각 사진에 대한 레이블을 저장하려고 하므로 이 데이터를 CreatePhoto돌연변이 입력의 일부로 받아들이고 쿼리를 통해 이 데이터를 검색할 수 있도록 GraphQL API를 업데이트해야 합니다.

유연하고 성능이 뛰어난 접근 방식은 Amazon Elasticsearch Service를 사용하여 사진 데이터를 인덱싱하고 검색 쿼리를 처리하는 것입니다. 다행히 Amplify CLI를 사용하면 Amazon Elasticsearch Service 엔드포인트를 생성하고 이를 앱 데이터에 매우 쉽게 연결할 수 있습니다.

 

 

 

Replace photoalbums/amplify/backend/api/photoalbums/schema.graphql

더보기
type Album 
@model 
@auth(rules: [
  {allow: owner},
  {allow: private, provider: iam}
]) {
    id: ID!
    name: String!
    photos: [Photo] @connection(keyName: "byAlbum", fields: ["id"])
}

type Photo 
@model 
@key(name: "byAlbum", fields: ["albumId"], queryField: "listPhotosByAlbum")
@auth(rules: [
  {allow: owner},
  {allow: private, provider: iam}
])
@searchable {
    id: ID!
    albumId: ID!
    album: Album @connection(fields: ["albumId"])
    bucket: String!
    fullsize: PhotoS3Info!
    thumbnail: PhotoS3Info!
    labels: [String]
}

type PhotoS3Info {
    key: String!
    width: Int!
    height: Int!
}

input CreatePhotoInput {
	id: ID
    owner: String
	albumId: ID!
	bucket: String!
	fullsize: PhotoS3InfoInput!
	thumbnail: PhotoS3InfoInput!
  labels: [String]
}

input PhotoS3InfoInput {
	key: String!
	width: Int!
	height: Int!
}

 

그 다음 amplify push

 

 

시간 초과 오류를 발견한 경우 amplify push위에서 Amazon Elasticsearch Service 엔드포인트를 프로비저닝하는 데 평소보다 시간이 오래 걸릴 수 있습니다. 일반적으로 다른 발급amplify push 몇 분 더 기다리면 문제가 해결됩니다.

 

Amplify의 @searchable GraphQL 지시문에 대한 자세한 내용은Amplify의 GraphQL 변환 문서.

 

 

변경한 사항

  • Amplify가 사진 데이터를 Amazon Elasticsearch Service 클러스터에 연결하도록 하는 @searchable 지시문을 사진 유형에 추가했습니다.
  • 사진 유형에  레이블 속성을 추가하여 사진 프로세서 기능에 의해 추가된 레이블 정보가 각 사진 레코드의 일부로 우리가 검색할 수 있도록 Elasticsearch Service로 스트리밍됩니다.

 

 

 

 

레이블로 사진 검색

모든 백엔드 작업이 완료되었으므로 이제 레이블별로 사진을 검색할 수 있도록 웹 앱을 업데이트하기만 하면 됩니다.

 Search 구성 요소를 만들어 루트 경로에 렌더링  App 구성 요소에 추가해 보겠습니다 . 

검색 구성 요소 에서 일치하는 모든 사진을 렌더링 하기 위해 이미 만든 PhotosList 구성 요소를 다시 사용합니다 .

 

photoalbums/src/App.js

더보기
import React, {useState, useEffect} from 'react';

import Amplify, {Auth} from 'aws-amplify'
import API, {graphqlOperation} from '@aws-amplify/api'
import Storage from '@aws-amplify/storage'
import aws_exports from './aws-exports'

import {S3Image, withAuthenticator} from 'aws-amplify-react'
import {Divider, Form, Grid, Header, Input, List, Segment} from 'semantic-ui-react'

import {BrowserRouter as Router, Route, NavLink} from 'react-router-dom';

import {v4 as uuid} from 'uuid';

import * as queries from './graphql/queries'
import * as mutations from './graphql/mutations'
import * as subscriptions from './graphql/subscriptions'

Amplify.configure(aws_exports);

function makeComparator(key, order = 'asc') {
  return (a, b) => {
    if (!a.hasOwnProperty(key) || !b.hasOwnProperty(key)) 
      return 0;
    
    const aVal = (typeof a[key] === 'string')
      ? a[key].toUpperCase()
      : a[key];
    const bVal = (typeof b[key] === 'string')
      ? b[key].toUpperCase()
      : b[key];

    let comparison = 0;
    if (aVal > bVal) 
      comparison = 1;
    if (aVal < bVal) 
      comparison = -1;
    
    return order === 'desc'
      ? (comparison * -1)
      : comparison
  };
}

const NewAlbum = () => {
  const [name,
    setName] = useState('')

  const handleSubmit = async(event) => {
    event.preventDefault();
    await API.graphql(graphqlOperation(mutations.createAlbum, {input: {
        name
      }}))
    setName('')
  }

  return (
    <Segment>
      <Header as='h3'>Add a new album</Header>
      <Input
        type='text'
        placeholder='New Album Name'
        icon='plus'
        iconPosition='left'
        action={{
        content: 'Create',
        onClick: handleSubmit
      }}
        name='name'
        value={name}
        onChange={(e) => setName(e.target.value)}/>
    </Segment>
  )
}

const AlbumsList = () => {
  const [albums,
    setAlbums] = useState([])

  useEffect(() => {
    async function fetchData() {
      const result = await API.graphql(graphqlOperation(queries.listAlbums, {limit: 999}))
      setAlbums(result.data.listAlbums.items)
    }
    fetchData()
  }, [])

  useEffect(() => {
    let subscription
    async function setupSubscription() {
      const user = await Auth.currentAuthenticatedUser()
      subscription = API.graphql(graphqlOperation(subscriptions.onCreateAlbum, {owner: user.username})).subscribe({
        next: (data) => {
          const album = data.value.data.onCreateAlbum
          setAlbums(a => a.concat([album].sort(makeComparator('name'))))
        }
      })
    }
    setupSubscription()

    return () => subscription.unsubscribe();
  }, [])

  const albumItems = () => {
    return albums
      .sort(makeComparator('name'))
      .map(album => <List.Item key={album.id}>
        <NavLink to={`/albums/${album.id}`}>{album.name}</NavLink>
      </List.Item>);
  }

  return (
    <Segment>
      <Header as='h3'>My Albums</Header>
      <List divided relaxed>
        {albumItems()}
      </List>
    </Segment>
  );
}

const AlbumDetails = (props) => {
  const [album, setAlbum] = useState({name: 'Loading...', photos: []})
  const [photos, setPhotos] = useState([])
  const [hasMorePhotos, setHasMorePhotos] = useState(true)
  const [fetchingPhotos, setFetchingPhotos] = useState(false)
  const [nextPhotosToken, setNextPhotosToken] = useState(null)

  useEffect(() => {
    const loadAlbumInfo = async() => {
      const results = await API.graphql(graphqlOperation(queries.getAlbum, {id: props.id}))
      setAlbum(results.data.getAlbum)
    }

    loadAlbumInfo()
  }, [props.id])

  useEffect(() => {
    fetchNextPhotos()
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  useEffect(() => {
    let subscription
    async function setupSubscription() {
      const user = await Auth.currentAuthenticatedUser()
      subscription = API.graphql(graphqlOperation(subscriptions.onCreatePhoto, {owner: user.username})).subscribe({
        next: (data) => {
          const photo = data.value.data.onCreatePhoto
          if (photo.albumId !== props.id) return
            setPhotos(p => p.concat([photo]))
        }
      })
    }
    setupSubscription()

    return () => subscription.unsubscribe();
  }, [props.id])


  const fetchNextPhotos = async () => {
    const FETCH_LIMIT = 20
    setFetchingPhotos(true)
    let queryArgs = {
      albumId: props.id,
      limit: FETCH_LIMIT, 
      nextToken: nextPhotosToken
    }
    if (! queryArgs.nextToken) delete queryArgs.nextToken
    const results = await API.graphql(graphqlOperation(queries.listPhotosByAlbum, queryArgs))
    setPhotos(p => p.concat(results.data.listPhotosByAlbum.items))
    setNextPhotosToken(results.data.listPhotosByAlbum.nextToken)
    setHasMorePhotos(results.data.listPhotosByAlbum.items.length === FETCH_LIMIT)
    setFetchingPhotos(false)
  }

  return (
    <Segment>
      <Header as='h3'>{album.name}</Header>
      <S3ImageUpload albumId={album.id} />
      <PhotosList photos={photos} />
      {
          hasMorePhotos && 
          <Form.Button
            onClick={() => fetchNextPhotos()}
            icon='refresh'
            disabled={fetchingPhotos}
            content={fetchingPhotos ? 'Loading...' : 'Load more photos'}
          />
      }
    </Segment>
  )
}


const S3ImageUpload = (props) => {
  const [uploading, setUploading] = useState(false)
  
  const uploadFile = async (file) => {
    const fileName = 'upload/'+uuid();
    const user = await Auth.currentAuthenticatedUser();

    const result = await Storage.vault.put(
      fileName, 
      file, 
      {
        metadata: { 
          albumid: props.albumId, 
          owner: user.username,
        }
      }
    );

    console.log('Uploaded file: ', result);
  }

  const onChange = async (e) => {
    setUploading(true)
    
    let files = [];
    for (var i=0; i<e.target.files.length; i++) {
      files.push(e.target.files.item(i));
    }
    await Promise.all(files.map(f => uploadFile(f)));

    setUploading(false)
  }

  return (
    <div>
      <Form.Button
        onClick={() => document.getElementById('add-image-file-input').click()}
        disabled={uploading}
        icon='file image outline'
        content={ uploading ? 'Uploading...' : 'Add Images' }
      />
      <input
        id='add-image-file-input'
        type="file"
        accept='image/*'
        multiple
        onChange={onChange}
        style={{ display: 'none' }}
      />
    </div>
  );
}

const PhotosList = React.memo((props) => {
  const PhotoItems = (props) => {
    return props.photos.map(photo =>
      <S3Image 
        key={photo.thumbnail.key} 
        imgKey={'resized/' + photo.thumbnail.key.replace(/.+resized\//, '')}
        level="private"
        style={{display: 'inline-block', 'paddingRight': '5px'}}
      />
    );
  }

  return (
    <div>
      <Divider hidden />
      <PhotoItems photos={props.photos} />
    </div>
  );
})


const Search = () => {
  const [photos, setPhotos] = useState([])
  const [label, setLabel] = useState('')
  const [hasResults, setHasResults] = useState(false)
  const [searched, setSearched] = useState(false)

  const getPhotosForLabel = async (e) => {
      setPhotos([])
      const result = await API.graphql(graphqlOperation(queries.searchPhotos, { filter: { labels: { match: label }} }));
      if (result.data.searchPhotos.items.length !== 0) {
          setHasResults(result.data.searchPhotos.items.length > 0)
          setPhotos(p => p.concat(result.data.searchPhotos.items))
      }
      setSearched(true)
  }

  const NoResults = () => {
    return !searched
      ? ''
      : <Header as='h4' color='grey'>No photos found matching '{label}'</Header>
  }

  return (
      <Segment>
        <Input
          type='text'
          placeholder='Search for photos'
          icon='search'
          iconPosition='left'
          action={{ content: 'Search', onClick: getPhotosForLabel }}
          name='label'
          value={label}
          onChange={(e) => { setLabel(e.target.value); setSearched(false);} }
        />
        {
            hasResults
            ? <PhotosList photos={photos} />
            : <NoResults />
        }
      </Segment>
  );
}

function App() {
  return (
    <Router>
      <Grid padded>
        <Grid.Column>
          <Route path="/" exact component={NewAlbum}/>
          <Route path="/" exact component={AlbumsList}/>
          <Route path="/" exact component={Search}/>

          <Route
            path="/albums/:albumId"
            render={() => <div>
            <NavLink to='/'>Back to Albums list</NavLink>
          </div>}/>
          <Route
            path="/albums/:albumId"
            render={props => <AlbumDetails id={props.match.params.albumId}/>}/>
        </Grid.Column>
      </Grid>
    </Router>
  )
}

export default withAuthenticator(App, {
  includeGreetings: true,
  signUpConfig: {
    hiddenDefaults: ['phone_number']
  }
})

 

변경한 사항

  • SearchPhotos 쿼리를 사용하여 지정된 레이블과 일치하는 사진 목록을 가져 오고 기존 PhotosList 구성 요소를 사용하여 사진을 렌더링 하는 검색 구성 요소를 추가했습니다 .
  • 루트 '/' 경로의 일부로 렌더링  SearchPhotos 구성 요소를 추가했습니다 .

 

완료되면 웹 앱에서 루트 경로 '/'로 돌아가서 검색을 시도할 수 있습니다.

Amplify가 Amazon Elasticsearch Service 통합을 설정할 때 생성 시 DynamoDB의 기존 데이터를 전달하지 않기 때문에 새 데이터만 인덱싱합니다. 검색 결과를 보려면 앨범에 사진을 몇 장 더 업로드해야 합니다.

 

 

 

실습~!

 

 

사진 검색을 테스트하려면 DynamoDB의 사진 테이블에서 검색어로 사용할 유효한 레이블을 찾으십시오. Rekognition에서 감지한 레이블과 정확히 일치하는 레이블을 입력해야 합니다.

 

dynamoDB에서 레이블을 확인 할수 있습니다.

 

 

반응형

'CLOUD > AWS' 카테고리의 다른 글

AWS API Gateway 정리  (0) 2021.09.09
[Amplifyphoto] 배포  (0) 2021.09.08
AWS cloud9 보안 토큰유지 하기  (0) 2021.09.06
Amplify function build 에러  (0) 2021.09.02
[Amplifyphoto] 썸네일 만들기  (0) 2021.09.01

댓글