[번역] Go와 Tensorflow로 이미지 인식 API 만들기
이 글의 원본은 여기에서 보실 수 있습니다.
혹시 더 나은 번역을 제안해주신다면 감사하게 수용하겠습니다.
Go와 Tensorflow로 이미지 인식 API 만들기 ¶
이번 튜토리얼에서는 Go 언어로 미리 학습된 Tensorflow의 Inception-V3 모델을 이용하여 이미지 인식 서비스를 만드는 방법을 배울 겁니다. 이 서비스는 Docker 컨테이너 내부에서 동작할 것이며, Go의 Tensorflow 패키지를 이용하여 입력받은 이미지를 처리하고, 해당 이미지를 가장 잘 서술하는 레이블 값을 반환할 것입니다.
전체 소스코드는 Github에서 이용 가능합니다.
시작하기 ¶
먼저 Docker와 Docker 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 'image=@./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 에서 받아보실 수 있습니다.