이 글의 원본은 여기에서 보실 수 있습니다.

혹시 더 나은 번역을 제안해주신다면 감사하게 수용하겠습니다.


Go와 Tensorflow로 이미지 인식 API 만들기


이번 튜토리얼에서는 Go 언어로 미리 학습된 Tensorflow의 Inception-V3 모델을 이용하여 이미지 인식 서비스를 만드는 방법을 배울 겁니다. 이 서비스는 Docker 컨테이너 내부에서 동작할 것이며, Go의 Tensorflow 패키지를 이용하여 입력받은 이미지를 처리하고, 해당 이미지를 가장 잘 서술하는 레이블 값을 반환할 것입니다.

전체 소스코드는 Github에서 이용 가능합니다.

시작하기

먼저 DockerDocker Compose 를 설치하세요.

컨테이너 설정하기

프로젝트의 루트 디렉토리 내부에, docker-compose.yaml 파일을 생성하세요.

version: '3.3'
services:
  api:
    container_name: 'api'
    build: './api'
    ports:
      - '8080:8080'
    volumes:
      - './api:/go/src/app'

그리고, api/Dockerfile 파일을 생성하세요. 우리는 Tensorflow 의 공식 도커 이미지를 기본 이미지로 사용할 것입니다. 또한 Go언어에서 Tensorflow를 사용하기 위해 Tensorflow C 라이브러리도 설치해야하죠.

FROM tensorflow/tensorflow
# Install TensorFlow C library
RUN curl -L \
   "https://storage.googleapis.com/tensorflow/libtensorflow/libtensorflow-cpu-linux-x86_64-1.3.0.tar.gz" | \
   tar -C "/usr/local" -xz
RUN ldconfig
# Hide some warnings
ENV TF_CPP_MIN_LOG_LEVEL 2

다음으로, 동일한 Dockerfile 내에 Go를 설치합니다. 해당 설정들은 도커 내의 공식 golang 이미지로부터 가져온 값들입니다.

RUN apt-get update && apt-get install -y --no-install-recommends \
		g++ \
		gcc \
		libc6-dev \
		make \
		pkg-config \
    wget \
    git \
	&& rm -rf /var/lib/apt/lists/*

ENV GOLANG_VERSION 1.9.1
RUN set -eux; \
	\
	dpkgArch="$(dpkg --print-architecture)"; \
	case "${dpkgArch##*-}" in \
		amd64) goRelArch='linux-amd64'; goRelSha256='07d81c6b6b4c2dcf1b5ef7c27aaebd3691cdb40548500941f92b221147c5d9c7' ;; \
		armhf) goRelArch='linux-armv6l'; goRelSha256='65a0495a50c7c240a6487b1170939586332f6c8f3526abdbb9140935b3cff14c' ;; \
		arm64) goRelArch='linux-arm64'; goRelSha256='d31ecae36efea5197af271ccce86ccc2baf10d2e04f20d0fb75556ecf0614dad' ;; \
		i386) goRelArch='linux-386'; goRelSha256='2cea1ce9325cb40839601b566bc02b11c92b2942c21110b1b254c7e72e5581e7' ;; \
		ppc64el) goRelArch='linux-ppc64le'; goRelSha256='de57b6439ce9d4dd8b528599317a35fa1e09d6aa93b0a80e3945018658d963b8' ;; \
		s390x) goRelArch='linux-s390x'; goRelSha256='9adf03574549db82a72e0d721ef2178ec5e51d1ce4f309b271a2bca4dcf206f6' ;; \
		*) goRelArch='src'; goRelSha256='a84afc9dc7d64fe0fa84d4d735e2ece23831a22117b50dafc75c1484f1cb550e'; \
			echo >&2; echo >&2 "warning: current architecture ($dpkgArch) does not have a corresponding Go binary release; will be building from source"; echo >&2 ;; \
	esac; \
	\
	url="https://golang.org/dl/go${GOLANG_VERSION}.${goRelArch}.tar.gz"; \
	wget -O go.tgz "$url"; \
	echo "${goRelSha256} *go.tgz" | sha256sum -c -; \
	tar -C /usr/local -xzf go.tgz; \
	rm go.tgz; \
	\
	if [ "$goRelArch" = 'src' ]; then \
		echo >&2; \
		echo >&2 'error: UNIMPLEMENTED'; \
		echo >&2 'TODO install golang-any from jessie-backports for GOROOT_BOOTSTRAP (and uninstall after build)'; \
		echo >&2; \
		exit 1; \
	fi; \
	\
	export PATH="/usr/local/go/bin:$PATH"; \
	go version

ENV GOPATH /go
ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH

RUN mkdir -p "$GOPATH/src" "$GOPATH/bin" && chmod -R 777 "$GOPATH"

필요한 Go 패키지들을 설치합니다.

RUN go get github.com/tensorflow/tensorflow/tensorflow/go \
  github.com/tensorflow/tensorflow/tensorflow/go/op \
  github.com/julienschmidt/httprouter

Inception 모델을 컨테이너 내부의 /model 디렉토리에 다운로드 받은 후 압축을 풀어줍니다.

RUN mkdir -p /model && \
  wget "https://storage.googleapis.com/download.tensorflow.org/models/inception5h.zip" -O /model/inception.zip && \
  unzip /model/inception.zip -d /model && \
  chmod -R 777 /model

API를 위한 계정을 하나 만들어줍니다.

RUN adduser --disabled-password --gecos '' api
USER api

그리고 우리의 소스코드 파일을 복사해주고, 어플리케이션을 설치한 후 실행시킵니다.

WORKDIR "/go/src/app"
COPY . .
RUN go install -v ./...
CMD [ "app" ]

api/main.go 파일을 생성합니다.

package main

import "fmt"

func main() {
  fmt.Println("Hello, Gophers")
}

이제 제대로 동작하는지 한번 실행시켜봅시다.

$ docker-compose -f docker-compose.yaml up --build

Tensorflow 모델 불러오기

위에서 압축 해제한 /model 내의 파일들은 이미지로부터 추출된 Tensorflow의 그래프와 해당 그래프의 의미를 가진 레이블 값들이 직렬화 된 형태로 저장되어 있습니다. 우리는 이 파일들을 불러와서 파싱할 필요가 있습니다.

먼저 main 함수에 변수를 정의해봅시다.

import (
  tf "github.com/tensorflow/tensorflow/go"
)
var (
	graph  *tf.Graph
	labels []string
)

func main() {
	if err := loadModel(); err != nil {
		log.Fatal(err)
		return
	}
}

자세한 import 에 대한 설명은 생략하려 합니다. 대개 IDE와 잘 통합된 Goimports 도구가 다 해주기 때문입니다. Go 짱짱! vim-go, vscode, goland 세 가지는 확실하게 해 준답니다 (역자 본인이 사용해봄)

loadModel 함수를 정의합시다.

func loadModel() error {
	// inception 모델 불러오기
	model, err := ioutil.ReadFile("/model/tensorflow_inception_graph.pb")
	if err != nil {
		return err
	}
	graph = tf.NewGraph()
	if err := graph.Import(model, ""); err != nil {
		return err
	}
	// 레이블 값 불러오기
	labelsFile, err := os.Open("/model/imagenet_comp_graph_label_strings.txt")
	if err != nil {
		return err
	}
	defer labelsFile.Close()
  scanner := bufio.NewScanner(labelsFile)
  // 레이블 값들은 한 줄 한 줄씩 나뉘어 있습니다
	for scanner.Scan() {
		labels = append(labels, scanner.Text())
	}
	if err := scanner.Err(); err != nil {
		return err
	}
	return nil
}

이미지 업로드하기

나중에 사용할 유틸리티 함수들을 정의합시다.

func responseError(w http.ResponseWriter, message string, code int) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(code)
	json.NewEncoder(w).Encode(map[string]string{"error": message})
}

func responseJSON(w http.ResponseWriter, data interface{}) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(data)
}

main 함수 내부에 새로운 HTTP 핸들러를 추가합니다.

func main() {
	if err := loadModel(); err != nil {
		log.Fatal(err)
		return
	}
	r := httprouter.New()
	r.POST("/recognize", recognizeHandler)
	log.Fatal(http.ListenAndServe(":8080", r))
}

recognizeHandler 함수를 정의합시다. 이미지 파일 데이터는 Form의 키값 “image” 로 존재한다고 가정합시다.

func recognizeHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
	// HTTP Request로부터 이미지를 읽어들이자
	imageFile, header, err := r.FormFile("image")
	// Filename에 파일명, 확장자가 포함되어 있음
	imageName := strings.Split(header.Filename, ".")
	if err != nil {
		responseError(w, "Could not read image", http.StatusBadRequest)
		return
	}
	defer imageFile.Close()
	var imageBuffer bytes.Buffer
	// 이미지 데이터를 Buffer로 복사
	io.Copy(&imageBuffer, imageFile)

    // ... 이후 추가될 부분 (HERE)
}

이미지 정규화하기

이미지를 분류하기 전에 이미지를 텐서로 변환한 후 정규화 시킬 필요가 있습니다. 왜냐하면 Inception 모델이 데이터의 특정한 포맷을 요구하기 때문입니다.

recognizeHandler 함수의 이후 추가될 부분 (HERE)에, 버퍼 내의 데이터와 파일 확장자를 makeTensorFromImage 함수에 넘겨주는 부분을 추가합시다.

tensor, err := makeTensorFromImage(&imageBuffer, imageName[:1][0])
if err != nil {
  responseError(w, "Invalid image", http.StatusBadRequest)
  return
}

정규화 그래프를 통해 이미지 텐서를 실행시키는 makeTensorFromImage 함수를 정의합시다.

func makeTensorFromImage(imageBuffer *bytes.Buffer, imageFormat string) (*tf.Tensor, error) {
	tensor, err := tf.NewTensor(imageBuffer.String())
	if err != nil {
		return nil, err
	}
	graph, input, output, err := makeTransformImageGraph(imageFormat)
	if err != nil {
		return nil, err
	}
	session, err := tf.NewSession(graph, nil)
	if err != nil {
		return nil, err
	}
	defer session.Close()
	normalized, err := session.Run(
		map[tf.Output]*tf.Tensor{input: tensor},
		[]tf.Output{output},
		nil)
	if err != nil {
		return nil, err
	}
	return normalized[0], nil
}

이미지 데이터를 받아 크기를 224x224 픽셀로 리사이징하고, 각 픽셀의 값들을 정규화 하는 그래프를 생성하는 makeTransformImageGraph 함수를 정의합시다.

func makeTransformImageGraph(imageFormat string) (graph *tf.Graph, input, output tf.Output, err error) {
	const (
		H, W  = 224, 224
		Mean  = float32(117)
		Scale = float32(1)
	)
	s := op.NewScope()
	input = op.Placeholder(s, tf.String)
	// PNG나 JPEG 디코딩
	var decode tf.Output
	if imageFormat == "png" {
		decode = op.DecodePng(s, input, op.DecodePngChannels(3))
	} else {
		decode = op.DecodeJpeg(s, input, op.DecodeJpegChannels(3))
	}
	// 각각의 픽셀에 대해 나누기와 빼기 연산 수행 (value-Mean)/Scale
	output = op.Div(s,
		op.Sub(s,
			// 이중선형보간법을 이용하여 이미지를 224x224 크기로 리사이징
			op.ResizeBilinear(s,
				// 단일 이미지를 포함하는 배치 작업 생성
				op.ExpandDims(s,
					// 디코딩 된 픽셀값 이용
					op.Cast(s, decode, tf.Float),
					op.Const(s.SubScope("make_batch"), int32(0))),
				op.Const(s.SubScope("size"), []int32{H, W})),
			op.Const(s.SubScope("mean"), Mean)),
		op.Const(s.SubScope("scale"), Scale))
	graph, err = s.Finalize()
	return graph, input, output, err
}

추론 실행하기

recognizeHandler 함수로 돌아와서, Inception 모델 그래프를 따라 정규화 된 이미지 텐서를 실행시킵시다.

session, err := tf.NewSession(graph, nil)
if err != nil {
  log.Fatal(err)
}
defer session.Close()
output, err := session.Run(
  map[tf.Output]*tf.Tensor{
    graph.Operation("input").Output(0): tensor,
  },
  []tf.Output{
    graph.Operation("output").Output(0),
  },
  nil)
if err != nil {
  responseError(w, "Could not run inference", http.StatusInternalServerError)
  return
}

output[0].Value() 텐서 값이 이제 각각의 레이블의 확률을 포함하게 되었습니다. 확률은 레이블이 입력받은 이미지를 얼마나 잘 서술하는지에 대한 값입니다.

최적의 레이블 찾기

마지막으로, 가장 확률이 높은 5개의 레이블을 반환하도록 합시다.

HTTP 응답으로 사용할 데이터를 표현할 구조체를 선언합시다.

type ClassifyResult struct {
	Filename string        `json:"filename"`
	Labels   []LabelResult `json:"labels"`
}

type LabelResult struct {
	Label       string  `json:"label"`
	Probability float32 `json:"probability"`
}

가장 높은 확률을 갖는 레이블들을 찾아내는 findBestLabels 함수를 정의합시다.

type ByProbability []LabelResult
func (a ByProbability) Len() int           { return len(a) }
func (a ByProbability) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
func (a ByProbability) Less(i, j int) bool { return a[i].Probability > a[j].Probability }

func findBestLabels(probabilities []float32) []LabelResult {
	// 레이블/확률 쌍의 리스트를 만듬
	var resultLabels []LabelResult
	for i, p := range probabilities {
		if i >= len(labels) {
			break
		}
		resultLabels = append(resultLabels, LabelResult{Label: labels[i], Probability: p})
	}
	// 확률에 따라 정렬
	sort.Sort(ByProbability(resultLabels))
	// 상위 5개의 레이블 반환
	return resultLabels[:5]
}

recognizeHandler 함수의 후반부에 HTTP 응답하는 부분을 마저 정의합니다.

responseJSON(w, ClassifyResult{
  Filename: header.Filename,
  Labels:   findBestLabels(output[0].Value().([][]float32)[0]),
})

그리고 나서 컨테이너를 다시 빌드해봅시다.

$ docker-compose -f docker-compose.yaml up -d --build

localhost:8080/recognize 엔드포인트로 몇몇 이미지를 입력으로 삼아 API를 호출해봅시다.

$ curl localhost:8080/recognize -F '[email protected]/cat.jpg'
{
  "filename": "cat.jpg",
  "labels": [
    { "label": "Egyptian cat", "probability": 0.39229771 },
    { "label": "weasel", "probability": 0.19872947 },
    { "label": "Arctic fox", "probability": 0.14527217 },
    { "label": "tabby", "probability": 0.062454574 },
    { "label": "kit fox", "probability": 0.043656528 }
  ]
}

고양이는 나름 잘 인식한다 송강호는 찾기 어려운듯...

실행 예시>>

결론

이제 완벽하게 동작하는 이미지 인식 서비스를 구축해보았습니다. 만약 조금 더 틈새시장(일반적인 레이블 대신에 특정한 레이블 값을 사용함)을 노려보고 싶다면, transfer learning 에 대해서 더 공부해보세요 (역자 주; 이 사이트 에서도 transfer learning에 대해서 자세하게 설명하고 있습니다). Tensorflow의 더 많은 모델들예제들 을 찾아서 Go로 구현시켜보세요.

전체 소스코드는 Github 에서 받아보실 수 있습니다.