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

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


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


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

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

시작하기

먼저 DockerDocker Compose 를 설치하세요.

컨테이너 설정하기

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

1
2
3
4
5
6
7
8
9
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 라이브러리도 설치해야하죠.

1
2
3
4
5
6
7
8
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 이미지로부터 가져온 값들입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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 패키지들을 설치합니다.

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

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

1
2
3
4
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를 위한 계정을 하나 만들어줍니다.

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

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

1
2
3
4
WORKDIR "/go/src/app"
COPY . .
RUN go install -v ./...
CMD [ "app" ]

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

1
2
3
4
5
6
7
package main
import "fmt"
func main() {
fmt.Println("Hello, Gophers")
}

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

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

Tensorflow 모델 불러오기

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 함수를 정의합시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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
}

이미지 업로드하기

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

1
2
3
4
5
6
7
8
9
10
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 핸들러를 추가합니다.

1
2
3
4
5
6
7
8
9
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” 로 존재한다고 가정합시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 함수에 넘겨주는 부분을 추가합시다.

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 함수를 정의합시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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 모델 그래프를 따라 정규화 된 이미지 텐서를 실행시킵시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 응답으로 사용할 데이터를 표현할 구조체를 선언합시다.

1
2
3
4
5
6
7
8
9
type ClassifyResult struct {
Filename string `json:"filename"`
Labels []LabelResult `json:"labels"`
}
type LabelResult struct {
Label string `json:"label"`
Probability float32 `json:"probability"`
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 응답하는 부분을 마저 정의합니다.

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
$ 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 에서 받아보실 수 있습니다.

Comments
  1. 1. Go와 Tensorflow로 이미지 인식 API 만들기
    1. 1.1. 시작하기
    2. 1.2. 컨테이너 설정하기
    3. 1.3. Tensorflow 모델 불러오기
    4. 1.4. 이미지 업로드하기
    5. 1.5. 이미지 정규화하기
    6. 1.6. 추론 실행하기
      1. 1.6.1. 최적의 레이블 찾기
    7. 1.7. 결론