14. 플러터 : 개와 고양이 사진 분류 (케라스) (5)

이번 프로젝트는 너무 힘들었기에
문제를 해결하는데 사용했던 몇 가지 함수를 정리한다.
훨씬 더 많은 코드를 만들어서 검증했지만
완성하고 나서 보니 대부분은 의미 없었던 함수였다.

첫 번째는 지정한 사진에 대해 정확도를 보여주는 코드다.
에뮬레이터에서 시험해 보면
결과가 너무 말도 안되게 나와서 어떤 문제인지 먼저 찾아야 했다.
데스크탑에서도 똑같이 말도 안되게 나오는지 확인이 필요했다.

나의 실수로 서로 다른 파일을 사용했던 관계로
데스크탑에서는 결과가 너무 잘 나왔다.
개와 고양이 모델 파일을 여러 개 만드는 과정에서 발생한
말도 안되는 실수로 일 주일은 날려 먹었다.

어쨌든 데스크탑과 스마트폰에서
동일한 결과가 나온다는 것을 검증하는 것은 중요하다.
물론 변환을 거쳤기 때문에 미세한 오차가 발생하는 것은 감안해야 한다.

scaling 옵션에 따라 결과가 다르게 나오는 점도 확인해야 한다.
True 옵션을 사용하는 것이 맞다.

import tensorflow as tf
from PIL import Image
import numpy as np


def load_image(img_path, scaling):
img = Image.open(img_path)
img = img.resize([150, 150])
img.load()
data = np.float32(img)

# 스케일링 유무에 따라 결과가 달라진다. 하는 것이 좋은 결과를 만든다.
# 학습할 때 스케일링을 적용했기 때문에 여기서도 적용하는 것이 맞다.
if scaling:
data /= 255
return data


def predict_model(model_path, images, scaling):
model = tf.keras.models.load_model(model_path)

stack = []
for img_path in images:
d = load_image(img_path, scaling=scaling)

d = d[np.newaxis] # (150, 150, 3) ==> (1, 150, 150, 3)
stack.append(d)

# (1, 150, 150, 3)으로 구성된 배열 결합 ==> (4, 150, 150, 3)
data = np.concatenate(stack, axis=0)

preds = model.predict(data)
print(preds.reshape(-1))


images = ['cats_and_dogs/small/test/cats/cat.1500.jpg',
'cats_and_dogs/small/test/cats/cat.1501.jpg',
'cats_and_dogs/small/test/dogs/dog.1500.jpg',
'cats_and_dogs/small/test/dogs/dog.1501.jpg']

predict_model('models/cats_and_dogs_small_4.h5', images, scaling=False)
predict_model('models/cats_and_dogs_small_4.h5', images, scaling=True)
# False : [0. 0. 1. 1.]
# True : [0.00184013 0.00273606 0.94292957 0.9738878 ]


앞의 코드보다 더 중요한 것이 있다.
같은 모델이라도 변환을 거쳤기 때문에
그 과정에서 원하지 않던 결과가 나올 수도 있다.

스마트폰에 사용한 모델을 데스크탑에서 사용할 수 있으면 좋지 않을까?
그래서 준비했다.
텐서플로 라이트 모델을 데스크탑에서 사용하는 코드.


def test_tflite_model(tflite_path, images):
interpreter = tf.lite.Interpreter(model_path=tflite_path)
interpreter.allocate_tensors()

# 입력 텐서 정보 : 인덱스를 알아야 데이터를 전달할 수 있다.
input_details = interpreter.get_input_details()
# [{'name': 'conv2d_60_input', 'index': 3, 'shape': array([ 1, 150, 150, 3], dtype=int32),
# 'dtype': <class 'numpy.float32'>, 'quantization': (0.0, 0)}]

# 출력 텐서 정보 : 인덱스를 알아야 결과를 받아올 수 있다.
output_details = interpreter.get_output_details()
# [{'name': 'dense_41/Sigmoid', 'index': 18, 'shape': array([1, 1], dtype=int32),
# 'dtype': <class 'numpy.float32'>, 'quantization': (0.0, 0)}]

result = []
for img_path in images:
input_data = load_image(img_path, scaling=True)
input_data = input_data[np.newaxis]

# 입력 데이터 전달
interpreter.set_tensor(input_details[0]['index'], input_data)
interpreter.invoke()

# 출력 데이터 읽기
output_data = interpreter.get_tensor(output_details[0]['index'])
result.append(output_data)

# 1차원 변환 출력
print(np.reshape(result, -1))


# 텐서플로 라이트 모델 경로와 시험할 사진 경로 전달
test_tflite_model('models/cats_and_dogs.tflite', images)


위의 코드는 내가 만든 코드는 아니고 스택 오버플로우를 참고했다.
여기서는 난수를 사용해서 어떤 데이터에 대해서도 동작하도록 처리하고 있다.
더 범용적인 코드라고 보면 된다.
나도 두고두고 봐야 하니까 코드를 붙여넣어 둔다.

How to import the tensorflow lite interpreter in Python?


def test_tflite_model_by_stack_overflow(tflite_path):
# Load TFLite model and allocate tensors.
interpreter = tf.contrib.lite.Interpreter(model_path=tflite_path)
interpreter.allocate_tensors()

# Get input and output tensors.
input_details = interpreter.get_input_details()
output_details = interpreter.get_output_details()

# Test model on random input data.
input_shape = input_details[0]['shape']

# 사진 경로 매개 변수는 없다. 난수로 만들면 되니까. 문법적으로 에러 나지 않는 것만 확인하면 된다.
input_data = np.array(np.random.random_sample(input_shape), dtype=np.float32)
interpreter.set_tensor(input_details[0]['index'], input_data)

interpreter.invoke()
output_data = interpreter.get_tensor(output_details[0]['index'])
print(output_data)


13. 플러터 : 개와 고양이 사진 분류 (케라스) (4)

이번 프로젝트의 마지막인 안드로이드 부분만 남았다.


6번
파이썬으로 작업한 딥러닝 모델 파일을 추가한다.
MainActivity.java 파일이 포함된 java 프로젝트와 같은 위치에 assets 폴더를 생성하고
cats_and_dogs.tflite 모델 파일을 붙여넣는다.



7번
gradle 파일을 수정한다.
위의 그림에서 profile 폴더 바로 아래 있는 파일이다.
같은 이름이 두 개 있어서 헷갈리기 딱 좋다.


android {
...

// 추가한 부분
aaptOptions {
noCompress "tflite"
}
}


dependencies {
testImplementation 'junit:junit:4.12'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'

// 추가한 부분
implementation 'org.tensorflow:tensorflow-lite:+'
}


8번
경로 수신 및 모델 연동 코드를 구현한다.
MainActivity.java 파일을 열고 아래 코드를 붙여넣는다.


package tflite.com.catdog.flutter_catdog;

import android.os.Bundle;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;

import android.app.Activity;
import android.content.res.AssetFileDescriptor;
import android.util.Log;

import java.io.FileInputStream;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;

import org.tensorflow.lite.Interpreter;

// 기본적으로 포함되어 있는 모듈
import io.flutter.app.FlutterActivity;
import io.flutter.plugins.GeneratedPluginRegistrant;

// MethodChannel 관련 모듈 (추가해야 함)
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
import io.flutter.plugin.common.MethodChannel.Result;
import io.flutter.plugin.common.MethodCall;

public class MainActivity extends FlutterActivity {
// 채널 문자열. 플러터에 정의한 것과 동일해야 한다.
private static final String mChannel = "catdog/predict";

@Override
protected void onCreate(Bundle savedInstanceState) {
// 빌트인 코드
super.onCreate(savedInstanceState);
GeneratedPluginRegistrant.registerWith(this);

// 추가한 함수.
new MethodChannel(getFlutterView(), mChannel).setMethodCallHandler(
new MethodCallHandler() {
@Override
public void onMethodCall(MethodCall call, Result result) {
// 각각의 메소드를 switch로 분류. 아이폰에서는 else if 사용.
switch (call.method) {
case "predictImage":
// Map<String, String> args = (HashMap) call.arguments;
// String path = args.get("path");
String path = call.argument("path");

final Bitmap bitmapRaw = BitmapFactory.decodeFile(path);

int cx = 150, cy = 150;
Bitmap bitmap = Bitmap.createScaledBitmap(bitmapRaw, cx, cy, false);

int[] pixels = new int[cx * cy];
bitmap.getPixels(pixels, 0, cx, 0, 0, cx, cy);

// 두 가지 방법 모두 동작. 입력 텐서는 자료형을 내부적으로 재해석한다.
// float[][][][] input_img = getInputImage_1(pixels, cx, cy);
ByteBuffer input_img = getInputImage_2(pixels, cx, cy);
// float[] input_img = getInputImage_3(pixels, cx, cy);

Interpreter tf_lite = getTfliteInterpreter("cats_and_dogs.tflite");

float[][] pred = new float[1][1];
tf_lite.run(input_img, pred);

final String predText = String.format("%f", pred[0][0]);
Log.d("예측", predText);
result.success(predText);
break;
default:
result.notImplemented();
}
}
}
);
}

// 입력 이미지와 동일한 형태의 배열 사용
private float[][][][] getInputImage_1(int[] pixels, int cx, int cy) {
float[][][][] input_img = new float[1][cx][cy][3];

int k = 0;
for (int y = 0; y < cy; y++) {
for (int x = 0; x < cx; x++) {
int pixel = pixels[k++]; // ARGB : ff4e2a2a

input_img[0][y][x][0] = ((pixel >> 16) & 0xff) / (float) 255;
input_img[0][y][x][1] = ((pixel >> 8) & 0xff) / (float) 255;
input_img[0][y][x][2] = ((pixel >> 0) & 0xff) / (float) 255;
}
}

return input_img;
}

// 다루기 편한 1차원 배열 사용
private ByteBuffer getInputImage_2(int[] pixels, int cx, int cy) {
ByteBuffer input_img = ByteBuffer.allocateDirect(cx * cy * 3 * 4);
input_img.order(ByteOrder.nativeOrder());

for (int i = 0; i < cx * cy; i++) {
int pixel = pixels[i]; // ARGB : ff4e2a2a

input_img.putFloat(((pixel >> 16) & 0xff) / (float) 255);
input_img.putFloat(((pixel >> 8) & 0xff) / (float) 255);
input_img.putFloat(((pixel >> 0) & 0xff) / (float) 255);
}

return input_img;
}

// 될 것 같지만 되지 않는 코드
private float[] getInputImage_3(int[] pixels, int cx, int cy) {
float[] input_img = new float[cx * cy * 3 * 4];

int k = 0;
for (int i = 0; i < cx * cy; i++) {
int pixel = pixels[i]; // ARGB : ff4e2a2a

input_img[k++] = ((pixel >> 16) & 0xff) / (float) 255;
input_img[k++] = ((pixel >> 8) & 0xff) / (float) 255;
input_img[k++] = ((pixel >> 0) & 0xff) / (float) 255;
}

return input_img;
}

// 모델 파일 인터프리터를 생성하는 공통 함수
// loadModelFile 함수에 예외가 포함되어 있기 때문에 반드시 try, catch 블록이 필요하다.
private Interpreter getTfliteInterpreter(String modelPath) {
try {
return new Interpreter(loadModelFile(MainActivity.this, modelPath));
} catch (Exception e) {
e.printStackTrace();
}
return null;
}

// 모델을 읽어오는 함수로, 텐서플로 라이트 홈페이지에 있다.
// MappedByteBuffer 바이트 버퍼를 Interpreter 객체에 전달하면 모델 해석을 할 수 있다.
private MappedByteBuffer loadModelFile(Activity activity, String modelPath) throws IOException {
AssetFileDescriptor fileDescriptor = activity.getAssets().openFd(modelPath);
FileInputStream inputStream = new FileInputStream(fileDescriptor.getFileDescriptor());
FileChannel fileChannel = inputStream.getChannel();
long startOffset = fileDescriptor.getStartOffset();
long declaredLength = fileDescriptor.getDeclaredLength();
return fileChannel.map(FileChannel.MapMode.READ_ONLY, startOffset, declaredLength);
}
}


코드를 교체하고 나서 문제가 발생했다면
오른쪽 상단에 있는 Setup SDK 메뉴를 선택해서 해결해야 한다.
아래와 같은 화면이 뜨고 적당한 버전을 선택해서 연결한다.



9번
개와 고양이 사진을 에뮬레이터로 찍기는 어렵다.
안드로이드 폰에 올려서 결과를 볼 수도 있겠지만
여기서는 에뮬레이터로 복사해서 가져다 쓰도록 한다.

[View - Tool Windows - Device File Explorer] 메뉴를 선택한다.
[sdcard - Download] 폴더에서 마우스 오른쪽 버튼을 눌러 업로드를 선택한다.
개와 고양이 폴더에서 각각 2개씩 추가했다.


  


4장의 사진을 업로드하고 나면 아래와 같이 확인할 수 있다.



이제 모든게 끝났다.
완성을 하지 못해서 계속 비공개로 유지했었다.
마지막으로 우재 폰을 사용해서 카메라 메뉴도 확인했다.
모니터에 있는 개와 고양이 사진을 얼마나 잘 분류하던지..
감동이었다.

일요일 12시 20분.
우재는 곤충 박사님이 부탁한 프로젝트를 하고 있고
서진이는 한동안 잊고 살았던 파이썬을 다시 공부하고 있다.
모두 흥미를 느끼고 있어 좋다.
일요일이 일요일이다.
다만 많이 시끄럽다. ^^

12. 플러터 : 개와 고양이 사진 분류 (케라스) (3)

모델 파일은 생성했고
이제 스마트폰에 올릴 코드를 구성하면 된다.
먼저 플러터 코드를 구성하고 다음 글에서 안드로이드 코드를 구현한다.


3번
안드로이드 스튜디오에서 flutter_catdog으로 플러터 프로젝트를 생성한다.
프로젝트 옵션에서는 지금처럼 코틀린 사용은 표시하지 않는다. ^^


4번
pubspec.yaml 파일을 열고 이미지 피커 모듈을 추가한다.
잊지 말고, 상단에 있는 Packages upgrade 메뉴를 눌러 업그레이드를 진행한다.


dependencies:
flutter:
sdk: flutter

# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^0.1.2
image_picker:


5번
안드로이드와 연동하는 코드를 구현한다.
사진과 카메라 중에서 선택할 수 있고
선택하거나 촬영한 사진으로부터 경로를 가져와서 안드로이드에 전달한다.


import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart'; // 이미지 피커

class CatDogNets extends StatefulWidget {
@override
CatDogNetsState createState() => CatDogNetsState();
}

class CatDogNetsState extends State<CatDogNets> {
static const MethodChannel mMethodChannel = MethodChannel('catdog/predict');

String mResult = 'No Photo!'; // 예측 결과를 수신할 문자열
File mPhoto; // 선택 또는 촬영한 사진. ImagePicker 반환값

@override
Widget build(BuildContext context) {
Widget photo = (mPhoto != null) ? Image.file(mPhoto) : Placeholder();

return Material(
child: Column(
children: <Widget>[
Expanded(
child: Center(child: photo),
),
Row(
children: <Widget>[Text(mResult, style: TextStyle(fontSize: 23))],
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
),
Row(
children: <Widget>[
RaisedButton(
child: Text('사진', style: TextStyle(fontSize: 23)),
onPressed: () => onPhoto(ImageSource.gallery),
),
RaisedButton(
child: Text('카메라', style: TextStyle(fontSize: 23)),
onPressed: () => onPhoto(ImageSource.camera),
),
],
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
)
],
mainAxisAlignment: MainAxisAlignment.end,
),
);
}

// 앨범과 카메라 양쪽에서 호출. ImageSource.gallery와 ImageSource.camera 두 가지밖에 없다.
void onPhoto(ImageSource source) async {
mPhoto = await ImagePicker.pickImage(source: source);

try {
var photoPath = <String, String>{'path': mPhoto.path};
String received = await mMethodChannel.invokeMethod('predictImage', photoPath);

// 안드로이드로부터 결과 수신하고, 선택한 사진으로 변경
mResult = received;
setState(() => {});
} on PlatformException {}
}
}

void main() {
runApp(MaterialApp(home: CatDogNets()));
}


11. 플러터 : 개와 고양이 사진 분류 (케라스) (2)

이전 글에서 정리한 순서대로
하나씩 하나씩 진행해 보도록 한다.


첫 번째로 개와 고양이를 구분하는 케라스 모델을 만든다.
이번 글에서 사용하는 코드는
"케라스 창시자에게 배우는 딥러닝"에서 가져왔다.
내가 할 수도 있겠지만
그런 정도의 정확도로 어찌 블로그에 올릴 수 있겠는가!
"케라스 창시자" 정말 좋은 책이니까
아직 보지 않았다면 꼭 구매하도록 하자.


깃헙에 가면 참고로 했던 소스 코드와 설명을 볼 수 있다.
여러 가지 버전이 있지만,
여기서는 정확도가 중요한 것이 아니기 때문에 첫 번째 버전을 사용하도록 한다.

5.2 - 소규모 데이터셋에서 컨브넷 사용하기
5.3 - 사전 훈련된 컨브넷 사용하기


(음.. 앞의 말은 취소다.
정확도가 안 나오는 걸로 했다가 무지막지하게 고생했다.
가장 좋은 버전을 사용하도록 하겠다.)


학습하고 검사할 개와 고양이 사진이 필요하다.
깃헙에 있는 데이터셋은
2천개의 사진을 같은 폴더에 두었고
이들을 3개의 폴더로 구분해서 저장하는 것부터 시작한다.
굳이 따라할 필요가 없어서
해당 파일을 각각의 폴더에 나누어서 저장한 압축 파일을 사용한다.
압축을 풀어서 파이썬 소스 파일이 있는 폴더로 붙여넣는다.

실패.
파일을 첨부하려고 했더니 용량 제한이 10mb.
압축 파일은 90mb.
할 수 없다. 직접 폴더를 만들고 파일을 복사해야겠다.

깃헙 프로젝트를 다운로드 받는다.
datasets 안에 들어가면 cats_and_dogs/train 폴더가 보인다.

  1. train 폴더와 같은 위치에 small 폴더 생성
  2. small 폴더 안에 train, valid, test 폴더 생성
  3. train, valid, test 폴더 각각에 cats와 dogs 폴더 생성
  4. 원본 파일이 있는 train 폴더의 고양이 사진 처음 1,000개를 small/train/cats 폴더로 복사
  5. 원본 파일이 있는 train 폴더의 개 사진 처음 1,000개를 small/train/dogs 폴더로 복사
  6. valid와 test 폴더에는 각각 500개씩 복사
  7. 파이썬 소프 파일이 있는 폴더로 cats_and_dogs 폴더 붙여넣기

폴더 생성 및 복사가 끝나면 아래와 같은 모습이 되어야 한다.
사진이 큼지막한게 보기가 아주 좋다. 시원하다.


여기서부터 이전 글에서 언급했던 순서대로 시작이다.


1번
먼저 모델을 생성해서 파일로 저장하는 코드다.
이번 코드에는 주석을 붙이지 않았다.
프로젝트의 목적이 플러터에서 이미 분류 모델을 구동하는 것이지
모델 자체를 설명하는 것이 아니니까.
설명이 필요하면 앞에서 언급한 깃헙 파일을 보도록 한다.


def save_model(model_path, train_path, valid_path):
# 이미지넷에서 가져온 사전 학습한 가중치는 수정하면 안됨
conv_base = tf.keras.applications.VGG16(weights='imagenet', include_top=False, input_shape=[150, 150, 3])

model = tf.keras.models.Sequential()
model.add(conv_base) # 기존 모델 연결
model.add(tf.keras.layers.Flatten())
model.add(tf.keras.layers.Dense(512, activation='relu'))
model.add(tf.keras.layers.Dropout(rate=0.5))
model.add(tf.keras.layers.Dense(1, activation='sigmoid'))

# 전체 동결. 이미지넷에서 가져온 사전 학습한 가중치는 수정하면 안됨
conv_base.trainable = False

model.compile(optimizer=tf.keras.optimizers.RMSprop(lr=0.0001),
loss='binary_crossentropy',
metrics=['acc'])

# 학습 데이터 증강
train_img_generator = tf.keras.preprocessing.image.ImageDataGenerator(
rescale=1/255,
rotation_range=20,
width_shift_range=0.1,
height_shift_range=0.1,
shear_range=0.1,
zoom_range=0.1,
horizontal_flip=True)
# 검증 제너레이터는 이미지 증강하면 안됨.
valid_img_generator = tf.keras.preprocessing.image.ImageDataGenerator(rescale=1/255)

batch_size = 32
train_generator = train_img_generator.flow_from_directory(
train_path,
target_size=[150, 150],
batch_size=batch_size,
class_mode='binary')
valid_generator = valid_img_generator.flow_from_directory(
valid_path,
target_size=[150, 150],
batch_size=batch_size,
class_mode='binary')

# 20회 에포크만큼 학습
model.fit_generator(train_generator,
steps_per_epoch=1000 // batch_size,
epochs=20,
validation_data=valid_generator,
validation_steps=50)

# hdf5 형식 : Hierarchical Data Format version 5
model.save(model_path)


model_path = 'models/cats_and_dogs_small.h5'
save_model(model_path, 'cats_and_dogs/small/train', 'cats_and_dogs/small/valid')


데스크탑을 우재와 서진이가 사용하고 있는 관계로
맥북에서 돌리고 있는데.. 4시간은 걸리는 것 같다.
그래서, 내가 만든 모델 파일을 첨부하려고 했다.
10mb 용량 제한에 걸렸다.
우재야 미안하다. 직접 만들어야겠다.


모델을 저장했다면 잘 동작하는지 확인할 차례다.
모델을 로딩해서 정확도를 확인해 본다.


def load_model(model_path, test_path):
model = tf.keras.models.load_model(model_path)

generator = tf.keras.preprocessing.image.ImageDataGenerator(rescale=1/255)

test_gen = generator.flow_from_directory(test_path,
target_size=[150, 150],
batch_size=500,
class_mode='binary')

# 첫 번째 배치 읽기. batch_size가 500이니까 500개 가져옴
x, y = next(test_gen)
print('acc :', model.evaluate(x, y))


load_model(model_path, 'cats_and_dogs/small/test')


아쉽지만, 정확도는 그리 잘나오는 편은 아니다.
여러 가지 버전 중에서 첫 번째 버전을 선택했으니까.
손실 값은 0.58, 정확도는 72.8% 나왔다.

acc : [0.5835418028831482, 0.728]


2번
마지막으로 저장한 모델 파일을 텐서플로 라이트 버전으로 변환한다.
변환은 늘 그렇듯이 이전 글에 나왔던 코드를 붙여넣어서 사용한다.


# 저장한 파일로부터 모델 변환 후 다시 저장
def convert_model(model_path, tflite_path):
converter = tf.lite.TFLiteConverter.from_keras_model_file(model_path)
flat_data = converter.convert()

with open(tflite_path, 'wb') as f:
f.write(flat_data)


convert_model('models/cats_and_dogs_small.h5', 'models/cats_and_dogs.tflite')


이걸로 파이썬 기반으로 작업할 내용은 모두 끝났고
여기서 만든 모델 파일을 안드로이드에서 사용할 것이다.

10. 플러터 : 개와 고양이 사진 분류 (케라스) (1)

여기까지만 하고 그만했으면 좋겠다.
여러 가지를 하려다 보니
매번 할 때마다 새롭게 느껴지는 부분들이 힘들다.


어쨌든 마지막으로
딥러닝으로 개와 고양이를 분류하는 모델을 만들고
플러터를 사용해서 스마트폰에서 직접 분류를 해보려고 한다.
동물을 사랑하느냐고 물으면
당연히 그렇다고 대답하겠지만
그것보다도 기존 예제들이 너무 복잡해서
이미지를 분류할 수 있는 가장 단순한 예제를 만들고 싶었다.

첫 번째 사진은 처음 실행했을 때,
두 번째 사진은 [사진] 메뉴를 선택했을 때를 보여준다.
에뮬레이터에서 사용할 사진은 테스트 폴더로부터 두 장씩 복사했다.

세 번째 사진은 개를 선택했을 때 94.03%,
네 번째 사진은 고양이를 선택했을 때 0.15% 확률로 개라는 것을 보여준다.
개/고양이 모델 학습이 잘 되었음을 알 수 있다.
출력 값은 시그모이드를 통과한 값이고 1은 개, 0은 고양이를 가리킨다.



딥러닝 모델은 안드로이드와 아이폰에서 직접 다루기 때문에
플러터는 앨범에서 가져오거나 카메라로 찍은 이미지를 전달하는 역할만 한다.
전체 구현에서는 파이썬, 플러터, 안드로이드 코드가 필요하고
아이폰은 구현하지 않으려고 한다.
이전에 했던 아이폰 코드를 안드로이드와 비교하면서 따라 하면 되니까.

여러 유형의 코드가 섞여 있어서
이번 글을 포함해서 다섯 번에 걸쳐 올린다.
아래 나열한 순서를 따라 차례대로 해보자.
참고로 마지막 글은
이번 프로젝트를 진행하면서 디버깅을 하기 위한 용도로 만든 코드를 넣었다.

이번 글 만드는데.. 2주 걸렸다.
내가 실수를 한 부분이 있긴 했지만..
그래도 너무 오래 걸렸다.
우재는 실수하고 찾지 못하는 아빠를 닮지 말고.
안 된다고 화내지 말고.
모든 건 자신의 잘못이라는 걸 겸허하게 인정하는 사람이 되라.
이제 한동안 화내지 않을께. ^^


# 딥러닝

1. 모델 구현 (케라스)
2. 모델을 텐서플로 라이트로 변환


# 플러터

3. flutter_catdog 프로젝트 생성
4. pubspce.yaml 파일 수정
5. 이미지 경로를 스마트폰에 전달하고 수신하는 코드 구현


# 안드로이드
# 플러터 프로젝트에 포함된 안드로이드 프로젝트 사용

6. 텐서플로 라이트 모델 파일 추가
7. gradle 파일 수정
8. 경로 수신 및 사진을 모델에 전달하고 결과 수신
9. 개와 고양이 사진 에뮬레이터에 복사

9. 플러터 : 플랫폼 채널 기본 (2)

플러터는 안드로이드 스튜디오에서 코딩한다.
다른 IDE도 가능하겠지만
안드로이드 프로젝트를 주로 안드로이드 스튜디오에서 했기 때문에
그나마 이게 편하다.

특이하게도 플러터 프로젝트에 포함된 아이폰 프로젝트는 xcode에서 코딩할 수도 있게
안드로이드 프로젝트는 별도의 안드로이드 스튜디오에서 코딩할 수도 있게 만들었다.
자신 있는 코드라면 별도의 IDE 없이 코딩하면 되겠지만
자동완성을 비롯해 불편한 점이 많을 수 있다.
여기서는 별도의 IDE를 열고 설명하는 것이 불편하기 때문에
플러터 프로젝트 안에서 모두 처리하도록 한다.
말했다시피 에러가 발생하면 굉장히 곤란할 수 있음을 명심한다.

프로젝트를 완성한 다음에 성공적으로 구동하면
아래와 같은 화면을 볼 수 있다.
왼쪽은 처음 구동했을 때,
오른쪽은 아이폰/안드로이드에 데이터를 수신한 후의 모습이다.


아이폰 먼저 한다.
아이폰 소스코드는 ios 폴더의 Runner 폴더의 AppDelegate.swift에 있다.
플러터 프로젝트를 생성할 때
스위프트 옵션을 설정했기 때문에 확장자로 swift가 붙었다.

스위프트의 코드는 참 간결하다.
그런데 옵셔널 등의 다른 언어와는 다른, 반드시 이해해야 하는 문법들이 존재한다.
이것저것 참고해서 코드를 구성하기는 했는데
훨씬 효율적이고 정리가 잘 된 코드가 존재할 거라 확신한다.

xcode에서 코드를 입력하는 것이 자동완성으로 인해 너무 편했다.
xcode에서 수정하면 안드로이드 스튜디오에서 바로 반영되니까 xcode를 사용하지 않기는 어렵겠다.
윈도우 운영체제라면.. 글쎼.. 어떻게 해야 하나?
아이폰 시뮬레이터 실행은 안드로이드 상단에 위치한 도구막대에서 선택하면 된다.
자꾸 안 뜬다고 화내지 말기로 하자.

소스코드에 대한 설명은 아래 있는 안드로이드 코드를 보면 될 것 같다.
스위프트를 다시 보기 싫어서 그런다.
보고 나면 또 잊어먹을 것이고 다시 볼 일이 없을 거라고 믿는다.
이 부분은 다른 개발자에게 맡기는 걸로.
가령, 큰 아들 우재?

import UIKit

import Flutter


@UIApplicationMain

@objc class AppDelegate: FlutterAppDelegate {

    override func application(

        _ application: UIApplication,

        didFinishLaunchingWithOptions

        options: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {

        let flutterView = window?.rootViewController as! FlutterViewController;

        let channel = FlutterMethodChannel(name: "mobile/parameters",

                                           binaryMessenger: flutterView)

        

        channel.setMethodCallHandler {

            (call: FlutterMethodCall, result: FlutterResult) in

            switch (call.method) {

            case "getMobileString":

                result("IOS")

            case "getMobileNumber":

                guard let args = call.arguments else {

                    return

                }

                let myArgs = args as! [String: Int32]

                let left = myArgs["left"]

                let rite = myArgs["rite"]

                result(left! * rite!)

            case "getMobileArray":

                let words = ["first", "second", "third"]

                result(words)

            default:

                result(FlutterMethodNotImplemented)

            }

        }

        

        // 빌트인 코드

        GeneratedPluginRegistrant.register(with: self)

        return super.application(application,

                                 didFinishLaunchingWithOptions: options)

    }

}


스위프트 버전을 만들 때 참고한 사이트를 소개한다.
특히 두 번째 사이트가 아이폰과 안드로이드 양쪽에 대해 체계적으로 설명하고 있다.
난 다 읽지는 않았고, 필요한 부분만 발췌해서 사용했다.

Flutter plugin: invoking iOS and Android method including parameters not working
Flutter Platform Channels


처음에 생각없이 오브젝티브-C로 만든 코딩을 했다.
어렵게 구한 코드가 스위프트가 아니었다는 이유로.
따로 저장하는 것도 이상하고 해서 오브젝티브-C 코드도 추가한다.
스위프트 옵션을 설정하지 않았을 때 사용한다.
AppDelegate.m 파일이다.

#include "AppDelegate.h"

#include "GeneratedPluginRegistrant.h"


@implementation AppDelegate


- (BOOL)application:(UIApplication *)application

        didFinishLaunchingWithOptions:(NSDictionary *)options {

    FlutterViewController* controller =

        (FlutterViewController*) self.window.rootViewController;


    // 채널 이름은 플러터와 정확히 일치해야 한다.

    FlutterMethodChannel* channel = [FlutterMethodChannel

        methodChannelWithName: @"mobile/parameters" binaryMessenger: controller];

    [channel setMethodCallHandler: ^(FlutterMethodCall* call, FlutterResult result) {

        if([@"getMobileString" isEqualToString: call.method]) {

            result(@"IOS");

        }

        else if([@"getMobileNumber" isEqualToString: call.method]) {

            // 전달 받은 key를 사용해서 데이터 추출

            NSNumber* left = call.arguments[@"left"];

            NSNumber* rite = call.arguments[@"rite"];


            // NSNumber 객체로부터 int 추출

            result(@(left.intValue * rite.intValue));

        }

        else if([@"getMobileArray" isEqualToString: call.method]) {

            NSArray* words = @[@"first", @"second", @"third"];

            result(words);

        }

        else {

            result(FlutterMethodNotImplemented);

        }

    }];


    // 빌트인 코드

    [GeneratedPluginRegistrant registerWithRegistry:self];

    return [super application:application didFinishLaunchingWithOptions:options];

}

@end


안드로이드는 아이폰과 비슷하다.
정확하게는 두 가지가 서로 비슷하다.
프로젝트를 구성하는 방법에 있어서 다르기 때문에 살짝 달라보이는 것일 뿐이다.

안드로이드 소스코드는
android, app, src, main, java 폴더를 따라 들어가면 마지막에 있는 MainActivity.java 파일에 있다.
플러터 프로젝트를 생성할 때
코틀린 옵션을 설정하지 않았기 때문에 확장자로 java가 붙었다.

플러터와 통신하기 위해서는
메소드 채널(MethodChannel) 객체를 만들고
메소드 채널 객체의 핸들러(setMethodCallHandler)를 등록하고
핸들러 안에서 처리할 이벤트를 구분하는 onMethodCall 함수를 구현하면 된다.

아래 코드에서 주의깊게 볼 부분은
플러터쪽으로 데이터를 전달하는 Result 객체에 반환값을 넣는 부분이다.
모든 데이터를 반환할 수 있어야 하기 때문에
Result 객체에는 무엇이건 넣을 수 있지만 1개만 넣을 수 있고, 플러터쪽에서 정확하게 자료형을 일치시켜야 한다.

package tf.com.gluesoft.tflite_flutter_1;

import android.os.Bundle;
import java.util.ArrayList; // 배열 사용 필수

// 기본적으로 포함되어 있는 모듈
import io.flutter.app.FlutterActivity;
import io.flutter.plugins.GeneratedPluginRegistrant;

// MethodChannel 관련 모듈 (추가해야 함)
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
import io.flutter.plugin.common.MethodChannel.Result;
import io.flutter.plugin.common.MethodCall;

public class MainActivity extends FlutterActivity {
// 채널 문자열. 플러터에 정의한 것과 동일해야 한다.
private static final String mChannel = "mobile/parameters";

@Override
protected void onCreate(Bundle savedInstanceState) {
// 빌트인 코드
super.onCreate(savedInstanceState);
GeneratedPluginRegistrant.registerWith(this);

// 추가한 함수.
new MethodChannel(getFlutterView(), mChannel).setMethodCallHandler(
new MethodCallHandler() {
@Override
public void onMethodCall(MethodCall call, Result result) {
// 각각의 메소드를 switch로 분류. 아이폰에서는 else if 사용.
switch (call.method) {
case "getMobileString":
result.success("ANDROID");
break;
case "getMobileNumber":
// 매개 변수 추출
final int left = call.argument("left");
final int rite = call.argument("rite");
result.success(left * rite);
break;
case "getMobileArray":
// 배열 생성 후 전달. success 함수에 넣으면 json으로 인코딩된다. 플러터에서는 json 문자열 디코딩.
ArrayList<String> words = new ArrayList<>();
words.add("one");
words.add("two");
words.add("three");
result.success(words);
break;
default:
result.notImplemented();
}
}
}
);
}
}


안드로이드 코드 구성 중에 문제가 있는 것처럼 보일 수도 있다.
가령, "Cannot resolve symbol android" 에러가 뜨지만 잘못된 것은 아니다.
플러터 프로젝트에서 안드로이드 프로젝트 인식을 말끔하게 하지 못하는 것처럼 보인다.

이 부분은 안드로이드 프로젝트만 떼어서
다른 안드로이드 스튜디오로 열어서 gradle sync를 해도 달라지지 않는다.

오른쪽 상단에 Setup SDK 명령이 뜨면,
gradle 파일에 있는 버전 선택하면 해결된다. 꼭 해당 버전을 선택해야 한다.

여러 가지가 섞이니까
뭘 만들던지 쉽게 되지 않는다.
잘 따라왔다.

8. 플러터 : 플랫폼 채널 기본 (1)

일단 처음 생각했던 부분까지 왔다.
지금 작성하는 시리즈까지 정리하고 나면
본업으로 삼아야만 하는 딥러닝에 집중할 수 있을 것이다.

플러터만으로 스마트폰 앱을 완벽하게 구성할 수는 없다.
모바일이라는 공통 분모가 상당 부분 존재하지만
아이폰과 안드로이드라는 이질적인 부분도 존재하기 때문이다.
이와 같은 이질적인 부분에 대해
플러터에서는 아이폰이나 안드로이드에서 코딩하는 것처럼 코딩할 수 있도록 해준다.

아이폰이나 안드로이드와 연동하기 위해서는 플랫폼 채널이 필요한데
이번 글에서는 기본이 되는 이 부분에 대해 집중하도록 하겠다.

플러터 프로젝트에서 생성한 버튼을 누르면
아이폰 시뮬레이터 또는 안드로이드 에뮬레이터로부터 해당 버튼에 맞는 데이터를 수신한 다음
플로터 프로젝트에서 만든 텍스트뷰에 결과를 표시한다.
통신 데이터의 종류로는 문자열 1개, 정수 1개, 문자열 배열 1개를 사용한다.

먼저 플러터 프로젝트를 생성한다.
프로젝트 이름은 tflite_flutter_1로 한다.

이후 글에서도 똑같이 설정해야 하는 부분이 있는데
아래 그림에 있는 것처럼 안드로이드 코딩에서 코틀린을 사용하지 않을 것이다.
한창 앱을 개발할 때는 코틀린이 없었다.
덕분에 배우지 않아도 됐고, 기본은 알고 있는 자바로 코딩을 진행할 것이다.
그러고 보니
이전 글에서도 안드로이드 프로젝트는 모두 자바로 코딩을 했다.

플러터 프로젝트를 만드는 것이 생소하면
플러터 카테고리에 있는 글을 몇 개 보고 오도록 한다.
따로 설명하지 않는다.

안드로이드의 코틀린(kotlin)은 선택하지 않았고
아이폰의 스위프트(swift)는 선택했다.
앱 개발 주력 언어가 스위프트였는데.. 그마저 가물가물 하는 중이다.


플러터 프로젝트에서 라이브러리와 같은 환경 설정은 pubspec.yaml 파일에서 처리한다.
이번엔 플랫폼 채널외에는 별게 없어 pubspec.yaml 파일을 수정하지 않는다.

lib 폴더 아래의 main.dart 파일을 연다.
스마트폰에 표시할 화면을 비롯해서 아이폰/안드로이드와 연동할 코드를 추가한다.
플러터를 비롯해서 다트까지, 코드에 대한 설명은 생략한다.
꼭 플러터 카테고리의 내용을 읽고 오도록 한다.

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class PlatformChannel extends StatefulWidget {
@override
PlatformChannelState createState() => PlatformChannelState();
}

// State를 상속 받아야 setState 함수를 사용해서 화면 구성을 쉽게 변경할 수 있다.
class PlatformChannelState extends State<PlatformChannel> {
// 채널 생성. 매개변수에 '/'는 중요하지 않다. 'mobile' 영역의 'parameters'라는 항목으로 분류하기 위해 사용.
// 채널을 여러 개 만들어도 되고, 채널 하나에 메소드를 여러 개 얹어도 된다.
// 여기서는 채널 1개에 매개변수와 반환값을 다르게 해서 메소드를 3개 사용한다.
static const MethodChannel mMethodChannel = MethodChannel('mobile/parameters');

// 아이폰/안드로이드에서 수신한 데이터 : 문자열, 정수, 문자열 배열
String mReceivedString = 'Not clicked.';
int mReceivedNumber = -1;
String mReceivedArray = 'Not received.';

// 아이폰/안드로이드로부터 문자열 수신 (비동기)
Future<void> getMobileString() async {
try {
// getMobileText 문자열은 안드로이드와 아이폰에 전달할 식별자.
// if문으로 문자열을 비교해서 코드를 호출하기 때문에 함수 이름과 똑같을 필요는 없다.
// 반환값은 정해진 것이 없고 모든 자료형 사용 가능
final String received = await mMethodChannel.invokeMethod('getMobileString');

// 상태를 변경하면 build 메소드를 자동으로 호출하게 된다.
// 상태를 변경한다는 뜻은 setState 함수를 호출하는 것을 뜻한다. 변수의 값을 바꾸는 것은 없어도 된다.
setState(() {
mReceivedString = received;
});
}
on PlatformException {
mReceivedString = 'Exception. Not implemented.';
}
}

// 아이폰/안드로이드로부터 정수 수신 (비동기)
Future<void> getMobileNumber() async {
try {
// 여러 개의 매개 변수는 맵을 통해 전달
const values = <String, dynamic>{'left': 3, 'rite': 9};
final int received = await mMethodChannel.invokeMethod('getMobileNumber', values);

setState(() {
mReceivedNumber = received;
});
}
on PlatformException {
mReceivedNumber = -999;
}
}

// 아이폰/안드로이드로부터 문자열 배열 수신 (비동기)
Future<void> getMobileArray() async {
try {
// 문자열 배열 수신
final List<dynamic> received = await mMethodChannel.invokeMethod('getMobileArray');

setState(() {
mReceivedArray = '${received[0]}, ${received[1]}, ${received[2]}';
});
}
on PlatformException {
mReceivedArray = 'Exception. Not implemented.';
}
}

// 화면 구성. 유저 인터페이스를 구축한다고 보면 된다.
// 안드로이드에서 사용하는 별도의 xml 파일 없이 직접 코딩으로 화면을 설계한다.
// 아이폰/안드로이드로부터 넘겨받은 데이터를 사용해서 위젯의 내용을 덮어쓴다.
@override
Widget build(BuildContext context) {
return Material(
child: Column(
children: <Widget>[
Column(
children: <Widget>[
Text(mReceivedString, style: TextStyle(fontSize: 23)),
RaisedButton(
child: Text('Get text!', style: TextStyle(fontSize: 23)),
onPressed: getMobileString,
),
],
),
Column(
children: <Widget>[
Text(mReceivedNumber.toString(), style: TextStyle(fontSize: 23)),
RaisedButton(
child: Text('Get number!', style: TextStyle(fontSize: 23)),
onPressed: getMobileNumber,
),
],
),
Column(
children: <Widget>[
Text(mReceivedArray, style: TextStyle(fontSize: 23)),
RaisedButton(
child: Text('Get array!', style: TextStyle(fontSize: 23)),
onPressed: getMobileArray,
),
],
),
],
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
),
);
}
}

// 플러터 프로젝트의 진입점(entry point)
void main() {
runApp(MaterialApp(home: PlatformChannel()));
}


많이 했다.
다음 글에서 아이폰과 안드로이드 코딩을 해보자.

7. 텐서플로 라이트 : 안드로이드 + mnist 모델(케라스) (2)

안드로이드 프로젝트를 만들자.
프로젝트 이름은 tflite_by_keras로 줬다.

프로젝트를 만들고 나서 실행하면 아래와 같은 결과를 볼 수 있다.
첫 번째는 숫자 이미지 3장을 업로드한 에뮬레이터의 Downloads 폴더를 보여주고
두 번째와 세 번째는 선택한 숫자의 이미지에 대해 예측한 결과를 보여준다.

먼저 app 폴더에 있는 build.gradle 파일이다.
이전 글에서 설명했듯이 압축하면 안되고 텐서플로 라이트 모듈을 사용하자.

apply plugin: 'com.android.application'

android {
// 생략..

aaptOptions {
noCompress "tflite"
}
}

dependencies {
// 생략..

implementation 'org.tensorflow:tensorflow-lite:+'
}


앞의 그림과 똑같이 만들어 보자.
상단에 이미지뷰, 중간에 텍스트뷰 10개, 하단에 버튼 1개.
간격을 균등하게 분배하기 위해 layout_weight 속성을 사용했다.
activity_main.xml 파일을 아래 코드로 바꾸자.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<FrameLayout
android:layout_weight="1"
android:layout_width="match_parent"
android:layout_height="0dp">
<ImageView
android:id="@+id/photo"
android:layout_gravity="center"
android:layout_width="300dp"
android:layout_height="300dp" />
</FrameLayout>

<LinearLayout
android:layout_width="match_parent"
android:layout_height="50dp"
android:orientation="horizontal">
<TextView
android:id="@+id/result_0"
android:text="result 0"
android:textSize="19sp"
android:gravity="center"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
<TextView
android:id="@+id/result_1"
android:text="result 1"
android:textSize="19sp"
android:gravity="center"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
<TextView
android:id="@+id/result_2"
android:text="result 2"
android:textSize="19sp"
android:gravity="center"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
<TextView
android:id="@+id/result_3"
android:text="result 3"
android:textSize="19sp"
android:gravity="center"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
<TextView
android:id="@+id/result_4"
android:text="result 4"
android:textSize="19sp"
android:gravity="center"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
</LinearLayout>

<LinearLayout
android:layout_width="match_parent"
android:layout_height="50dp"
android:orientation="horizontal">
<TextView
android:id="@+id/result_5"
android:text="result 5"
android:textSize="19sp"
android:gravity="center"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
<TextView
android:id="@+id/result_6"
android:text="result 6"
android:textSize="19sp"
android:gravity="center"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
<TextView
android:id="@+id/result_7"
android:text="result 7"
android:textSize="19sp"
android:gravity="center"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
<TextView
android:id="@+id/result_8"
android:text="result 8"
android:textSize="19sp"
android:gravity="center"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
<TextView
android:id="@+id/result_9"
android:text="result 9"
android:textSize="19sp"
android:gravity="center"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
</LinearLayout>

<Button
android:id="@+id/button"
android:text="숫자 선택"
android:textSize="19sp"
android:layout_width="match_parent"
android:layout_height="50dp" />
</LinearLayout>


마지막으로 안드로이드 코드가 포함된 MainActivity.java 파일이다.
이미지를 앨범에서 선택하는 코드와
선택한 이미지를 비트맵으로 변환하는 코드와
비트맵을 바이트 배열로 변환하는 코드와
텐서플로 라이트 모델을 적용하는 코드와
예측 결과를 텍스트뷰에 출력하는 코드로 이루어져 있다.

맨 아래 있는 함수 2개는 공통 함수라서 안 봐도 되고
onCreate와 onActivityResult 함수만 보도록 한다.
앞에 설명했던 것처럼 각각의 코드가 어떤 역할을 하는지 정리할 수 있어야 한다.

package com.example.tflite_by_keras;

import android.app.Activity;
import android.content.res.AssetFileDescriptor;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;

import org.tensorflow.lite.Interpreter;

import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.util.Arrays;


public class MainActivity extends AppCompatActivity {
private static final int FROM_ALBUM = 1; // onActivityResult 식별자
private static final int FROM_CAMERA = 2; // 카메라는 사용 안함

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

// 인텐트의 결과는 onActivityResult 함수에서 수신.
// 여러 개의 인텐트를 동시에 사용하기 때문에 숫자를 통해 결과 식별(FROM_ALBUM 등등)
findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
Intent intent = new Intent();
intent.setType("image/*"); // 이미지만
intent.setAction(Intent.ACTION_GET_CONTENT); // 카메라(ACTION_IMAGE_CAPTURE)
startActivityForResult(intent, FROM_ALBUM);
}
});
}

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
// 카메라를 다루지 않기 때문에 앨범 상수에 대해서 성공한 경우에 대해서만 처리
if (requestCode != FROM_ALBUM || resultCode != RESULT_OK)
return;

try {
// 선택한 이미지에서 비트맵 생성
InputStream stream = getContentResolver().openInputStream(data.getData());
Bitmap bmp = BitmapFactory.decodeStream(stream);
stream.close();

ImageView iv = findViewById(R.id.photo);
iv.setScaleType(ImageView.ScaleType.FIT_XY); // [300, 300]에 꽉 차게 표시
iv.setImageBitmap(bmp);

// ---------------------------------------- //
// 검증 코드. 여러 차례 변환하기 때문에 PC 버전과 같은지 확인하기 위해 사용.

// mnist 원본은 0~1 사이의 실수를 사용해 픽셀을 표현한다. 픽셀 1개에 1바이트가 아니라 4바이트 사용.
// 메모리 용량은 3136(28 * 28 * 4). 입력 이미지를 똑같이 만들어서 전달해야 한다.

// mnist에서 생성한 숫자 이미지 파일이 흑백이긴 하지만 ARGB를 사용해서 색상을 표시하기 때문에
// 가운데 픽셀의 경우 fffcfcfc와 같은 형태로 나온다.
// ff는 alpha를 가리키고 동일한 값인 fc가 RGB에 공통으로 나온다.

// getPixel 함수는 int를 반환하기 때문에 부호 없는 16진수로 확인해야 한다.
// int pixel = bmp.getPixel(14, 14);
// Log.d("getPixel", Integer.toUnsignedString(pixel, 16));

// 원본 mnist 이미지는 (28, 28, 1)로 되어 있다.
// getByteCount 함수로 확인해 보면 3136으로 나오는데
// 각각의 픽셀이 4바이트로 구성되어 있기 때문에 그렇다. 784 * 4 = 3136
// int bytes = bmp.getByteCount();
// Log.d("getByteCount", Integer.toString(bytes));

// mnist 원본 이미지와 비교하기 위해 줄 단위로 변환 결과 출력
// 파이썬에서 똑같은 파일을 읽어들여서 에뮬레이터 출력과 비교. 똑같이 나온다. 성공.
// 2차원 배열을 한 번에 깔끔하게 출력할 수 없기 때문에 아래 코드가 필요하다.
// float[] row = new float[28];
// for(int y = 0; y < 28; y++) {
// for(int x = 0; x < 28; x++) {
// int pixel = bmp.getPixel(x, y); // x가 앞쪽, y가 뒤쪽.
// row[x] = (pixel & 0xff) / (float) 255; // 실수 변환하지 않으면 0과 1로만 나온다.
// }
// // 줄 단위 출력. 그래도 자릿수가 맞지 않아 numpy처럼 나오진 않는다.
// Log.d(String.format("%02d", y), Arrays.toString(row));
// }

// ---------------------------------------- //

// 비트맵 이미지로부터 RGB에 해당하는 값을 1개만 가져와서
// mnist 원본과 동일하게 0~1 사이의 실수로 변환하고, 1차원 784로 만들어야 한다.
// 그러나, 실제로 예측할 때는 여러 장을 한 번에 전달할 수 있어야 하기 때문에
// 아래와 같이 2차원 배열로 만드는 것이 맞다.
// 만약 1장에 대해서만 예측을 하고 싶다면 1차원 배열로 만들어도 동작한다.
float[][] bytes_img = new float[1][784];

for(int y = 0; y < 28; y++) {
for(int x = 0; x < 28; x++) {
int pixel = bmp.getPixel(x, y);
bytes_img[0][y*28+x] = (pixel & 0xff) / (float) 255;
}
}

// 파이썬에서 만든 모델 파일 로딩
Interpreter tf_lite = getTfliteInterpreter("mnist.tflite");

// 케라스로부터 변환할 때는 입력이 명시되지 않기 때문에 입력을 명확하게 정의할 필요가 있다.
// 이때 getInputTensor 함수를 사용한다. getOutputTensor 함수도 있다.
// 입력은 1개밖에 제공하지 않았고, 784의 크기를 갖는 1차원 이미지.
// 입력이 1개라는 뜻은 getInputTensor 함수에 전달할 인덱스가 0밖에 없다는 뜻이다.
// 여러 장의 이미지를 사용하면 shape에 표시된 1 대신 이미지 개수가 들어간다.
// input : [1, 784]
// Tensor input = tf_lite.getInputTensor(0);
// Log.d("input", Arrays.toString(input.shape()));

// 출력 배열 생성. 1개만 예측하기 때문에 [1] 사용
// bytes_img에서처럼 1차원으로 해도 될 것 같은데, 여기서는 에러.
float[][] output = new float[1][10];
tf_lite.run(bytes_img, output);

Log.d("predict", Arrays.toString(output[0]));

// 텍스트뷰 10개. 0~9 사이의 숫자 예측
int[] id_array = {R.id.result_0, R.id.result_1, R.id.result_2, R.id.result_3, R.id.result_4,
R.id.result_5, R.id.result_6, R.id.result_7, R.id.result_8, R.id.result_9};

for(int i = 0; i < 10; i++) {
TextView tv = findViewById(id_array[i]);
tv.setText(String.format("%.5f", output[0][i])); // [0] : 2차원 배열의 첫 번째
}

} catch (Exception e) {
e.printStackTrace();
}
}

// 모델 파일 인터프리터를 생성하는 공통 함수
// loadModelFile 함수에 예외가 포함되어 있기 때문에 반드시 try, catch 블록이 필요하다.
private Interpreter getTfliteInterpreter(String modelPath) {
try {
return new Interpreter(loadModelFile(MainActivity.this, modelPath));
}
catch (Exception e) {
e.printStackTrace();
}
return null;
}

// 모델을 읽어오는 함수로, 텐서플로 라이트 홈페이지에 있다.
// MappedByteBuffer 바이트 버퍼를 Interpreter 객체에 전달하면 모델 해석을 할 수 있다.
private MappedByteBuffer loadModelFile(Activity activity, String modelPath) throws IOException {
AssetFileDescriptor fileDescriptor = activity.getAssets().openFd(modelPath);
FileInputStream inputStream = new FileInputStream(fileDescriptor.getFileDescriptor());
FileChannel fileChannel = inputStream.getChannel();
long startOffset = fileDescriptor.getStartOffset();
long declaredLength = fileDescriptor.getDeclaredLength();
return fileChannel.map(FileChannel.MapMode.READ_ONLY, startOffset, declaredLength);
}
}


벌써 일요일이 다가고 있다.

내일부터는 수업 때문에 바쁠 것이고
나는 또 지금 작성했던 내용을 기억으로부터 밀어낼 것이고.

어찌 됐든 지금 코드를 활용하면 플러터 버전으로 변환할 수 있다.
플러터에서는 이미지 선택과 결과 표시를 담당하고
안드로이드에서는 입력으로 들어온 이미지를 모델에 전달해서 결과를 받는다.
아직 플러터에서 텐서플로 라이트에 대해 공식적으로 지원하지 않고 있어서
안드로이드 혹은 아이폰 코드와 연동하지 않을 수 없다.

하고 나니 복잡할 게 없는데
왜 1주일씩이나 걸린 것일까..?
욕하고 싶다.

6. 텐서플로 라이트 : 안드로이드 + mnist 모델(케라스) (1)

딥러닝 모델을 스마트폰과 연동하고 있는데
왜 이리 할게 많은 것일까?
기본 단계에서는 사진까지만 처리할 수 있으면 되는데..

케라스 모델을 텐서플로 라이트 버전으로 수정하는 것은 또 달랐다.
입력과 출력을 지정하지 않는 것은 좋은데
스마트폰에서는 어떻게 해야 하는 것일까?

사진을 다루기 때문에 코드가 꽤 길다.
파이썬으로 모델을 만들고
해당 모델을 안드로이드에서 가져다 사용하는 방식으로 진행한다.

ㅎㅎ
이전 글에서 새로 바뀐 편집기가 코드 색상을 지원하지 않아서 엄청 욕했다.
확인해 보니 예전 편집기를 사용할 수 있는 방법이 있었다.
불편하긴 하지만, 코드를 여러 색상으로 보여주는 건 타협할 수 없다.

먼저 mnist 데이터셋을 소프트맥스 알고리즘으로 분류하는 모델을 저장한다.
케라스를 사용하기 때문에 코드가 참 짧고 보기 좋다.
에포크는 15번 반복한다. 정확도는 92%를 조금 넘고, 파일 이미지에 대해서는 30장 기준으로 98%를 넘는다.

import tensorflow as tf
from tensorflow.examples.tutorials.mnist import input_data
import os
import numpy as np
import PIL

np.set_printoptions(linewidth=1000)


def save_model(h5_path, model_path):
model = tf.keras.Sequential()
model.add(tf.keras.layers.Dense(10, activation='softmax', input_shape=[784]))

model.compile(optimizer=tf.keras.optimizers.Adam(lr=0.001),
loss=tf.keras.losses.sparse_categorical_crossentropy,
metrics=['acc'])

mnist = input_data.read_data_sets('mnist')
model.fit(mnist.train.images, mnist.train.labels,
validation_data=[mnist.validation.images, mnist.validation.labels],
epochs=15, batch_size=128, verbose=0)

# 케라스 모델과 변수 모두 저장
model.save(h5_path)

# -------------------------------------- #

# 저장한 파일로부터 모델 변환 후 다시 저장
converter = tf.lite.TFLiteConverter.from_keras_model_file(h5_path)
flat_data = converter.convert()

with open(model_path, 'wb') as f:
f.write(flat_data)


save_model('./model/mnist.h5', './model/mnist.tflite')


이전에 만든 글에서 mnist 데이터셋을 파일로 저장하는 내용을 다뤘다.

mnist 숫자를 파일로 저장

왜 직접 손으로 써서 카메라로 촬영하면 안 되는 것인지에 대해 설명했다.
그렇다면 저장한 파일을 읽어와야 해당 숫자에 대해 예측할 수 있다는 뜻이다.
특정 폴더에 포함된 모든 파일을 읽어오는 함수를 만들었다.
주의할 점은 이 폴더의 내용은 반드시 mnist 숫자 이미지를 변환한 파일이어야 한다.
나는 이전 글의 코드를 사용해서 숫자별로 5장씩 50장을 저장했다.
그러나, 안드로이드 에뮬레이터에는 3장만 저장해서 사용했다.

다시 얘기하겠지만 중요한 점이 있다.
반드시 PC와 에뮬레이터의 파일은 같은 내용이어야 한다.
물론 여기서 만든 예제는 잘 동작하기 때문에 비교하지 않아도 되지만
이 부분 때문에 1주일이나 걸렸다.
잘 안되니까 하기 싫고.. 중간에 서핑도 조금 하고.

def read_testset(dir_path):
filenames = os.listdir(dir_path)
# filenames = ['0_003.png', '1_005.png', '2_013.png']

# images를 배열이 들어있는 리스트로 생성하면 에러
images = np.zeros([len(filenames), 784], dtype=np.float32)
labels = np.zeros([len(filenames)], dtype=np.int32)

for i, filename in enumerate(filenames):
img = PIL.Image.open(os.path.join(dir_path, filename))
# gray 스케일 변환
img = img.convert("L")
# 원본이 mnist와 맞지 않는다면 필요
img = img.resize([28, 28])
# PIL로 읽어오면 0~255까지의 정수. 0~1 사이의 실수로 변환.
# 생략하면 소프트맥스 결과가 하나가 1이고 나머지는 모두 0. 확률 개념이 사라짐.
# 255로 나누면 원본과 비교해서 오차가 있긴 하지만, 정말 의미 없는 수준이라서 무시해도 된다.
img = np.uint8(img) / 255
# 2차원을 mnist와 동일한 1차원으로 변환. np로 변환 후에 reshape 호출
images[i] = img.reshape(-1)

# 레이블. 이름의 맨 앞에 정답 있다.
finds = filename.split('_') # 0_003.png
labels[i] = int(finds[0]) # 0

return images, labels


read_testset 함수를 사용해서 특정 폴더의 내용을 모두 가져와서 예측해 보자.
나는 new_data라는 이름의 폴더를 사용했다.

def load_model(h5_path, dir_path):
model = tf.keras.models.load_model(h5_path)

mnist = input_data.read_data_sets('mnist')
print('mnist :', model.evaluate(mnist.test.images, mnist.test.labels))

# 파일로 변환한 mnist 숫자 이미지 파일 읽
images, labels = read_testset(dir_path)
print('files :', model.evaluate(images, labels))

# 에뮬레이터 결과와 비교 목적
preds = model.predict(images)
print(preds)


load_model('./model/mnist.h5', './mnist/new_data')

# [출력 결과]
# 10000/10000 [==============================] - 0s 21us/sample - loss: 0.2636 - acc: 0.9262
# mnist : [0.26363739772737027, 0.9262]
# 50/50 [==============================] - 0s 64us/sample - loss: 0.1373 - acc: 0.9800
# files : [0.13729526340961457, 0.98]
# [[9.81748290e-03 9.09259825e-07 2.68990714e-02 1.55930538e-02 2.32001161e-03 3.85744683e-02 8.65096133e-03 1.09144221e-05 8.93410563e-01 4.72251419e-03]
# [9.44603598e-05 1.64911069e-06 2.58899899e-03 4.38177539e-03 4.03641279e-05 6.74966127e-02 2.20050606e-06 3.07728811e-08 9.24911320e-01 4.82621283e-04]
# [5.07208024e-05 1.35197956e-06 3.72361648e-03 3.30108742e-04 9.75759685e-01 8.81002983e-04 8.18982720e-04 5.65604598e-04 9.81913181e-04 1.68870017e-02]
# 이하 생략


추가로 모델과 상관없지만
결과가 너무 이상하게 나왔었기 때문에
에뮬레이터와 계속해서 비교하지 않을 수 없었다.
가장 중요했던 것은 원본 이미지와 에뮬레이터에서 사용하는 이미지의 픽셀 값이 같은 것인지,였다.
그래서 PC에서는 아래 함수를 만들어서 사용했다.

# 파일 이미지 출력
def show_image_values(file_path):
img = PIL.Image.open(file_path)

img = img.convert("L")
img = img.resize([28, 28])
img = np.uint8(img) / 255

print(img)


# pc와 에뮬레이터에 같은 파일을 넣고 실제 값 출력해서 비교.
# 똑같이 나왔다. 변환이 잘 되었다는 뜻.
show_image_values('./mnist/new_data/2_013.png')

# [출력 결과] 너무 길어서 앞뒤의 0에 대해 공백을 제거했다.
# 생략..
# [0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.09411765 0.9254902 0.44313725 0. 0. 0. 0. 0.5254902 0.99215686 0.99215686 0.25490196 0. 0. 0. 0. 0. 0.]
# [0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.00392157 0.03529412 0. 0. 0. 0. 0.28627451 0.97647059 0.99215686 0.52156863 0.01176471 0. 0. 0. 0. 0. 0.]
# [0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.08627451 0.97647059 0.99215686 0.84705882 0.12156863 0. 0. 0. 0. 0. 0. 0.]
# [0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.23529412 0.96078431 0.99215686 0.92156863 0.1254902 0. 0. 0. 0. 0. 0. 0. 0.]
# [0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.21568627 0.80784314 0.99215686 0.98431373 0.45882353 0. 0. 0. 0. 0. 0. 0. 0. 0.]
# 생략..


이번 글에서 필요한 것은
텐서플로 라이트 모델로 변환한 "mnist.tflite" 모델 파일이다.
나머지는 중간 과정.

5. 텐서플로 라이트 : 안드로이드 + AND 모델(텐서플로) (2)

어제 저녁부터 해서 밤 늦게까지 했고
오늘 아침부터 했는데.. 벌써 점심 먹을 때가 됐다.
하고 나면 별거 아닌데..
왜 이렇게 많은 시간이 걸리는 건지..

안드로이드 프로젝트의 이름은 LogicalAnd로 했다.
프로젝트 생성 및 환경 설정은 이전 글을 참고하자.

파이썬에서 만든 모델 파일 2개를 assets 폴더로 옮겼을려나..?
그리고 해야 할 것이
텐서플로 라이트 라이브러리 관련 코드를 build.gradle 파일에 추가하는 것을 잊어먹으면 안된다.
내가 만든 문서가 생각이 안나서
다시 보면서 프로젝트를 하고 있는데
build.gradle 파일에 코드 추가하는 것을 잊어먹어서 30분 날려먹었다.
애초 지금처럼 써놨어야 했는데..

xml 파일의 내용을 아래 코드로 대체한다.
이전 예제하고 비슷한데, 텍스트뷰의 높이를 높였고 버튼을 1개 줄였다.

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical">

<TextView
android:id="@+id/tv_output"
android:text="결과 출력"
android:textSize="23dp"
android:gravity="center"
android:layout_width="match_parent"
android:layout_height="200dp" />

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">

<Button
android:id="@+id/button_1"
android:text="hx only"
android:textSize="19dp"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="100dp" />

<Button
android:id="@+id/button_2"
android:text="hx multi"
android:textSize="19dp"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="100dp" />

<Button
android:id="@+id/button_3"
android:text="hx + logic"
android:textSize="19dp"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="100dp" />

</LinearLayout>
</LinearLayout>
</FrameLayout>


다음으로 MainActivity.java 파일을 아래 코드로 대체하자.
코드에 대한 설명은 주석에 충분히 했으니까.. 생략한다.

주의 깊게 봐야 할 부분은 입력과 출력 배열 구성이다.
무엇을 생각했건.. 그보다 복잡해서 고생했다.

package com.example.logicaland;

import android.app.Activity;
import android.content.res.AssetFileDescriptor;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.TextView;

import org.tensorflow.lite.Interpreter;

import java.io.FileInputStream;
import java.io.IOException;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;

public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

// hx 연산 1개만 처리 (and_model_for_hx_only.tflite 파일)
findViewById(R.id.button_1).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
try {
// {{0}, {0}}는 파이썬에서의 [0, 0]과 같다.
// 이전 예제에서 보면 [0, 0]를 처리하기 위해 {{0}, {0}}를 사용했었다.
// 그래서 복잡하긴 하지만 3차원의 형태가 되어야 한다.
float[][][] input = new float[][][]{{{0}, {0}}, {{0}, {1}}, {{1}, {0}}, {{1}, {1}}};

// hx 연산은 0과 1 사이의 실수를 반환하기 때문에 float 타입으로 선언
float[][] output = new float[][]{{0}, {0}, {0}, {0}};

Interpreter tflite = getTfliteInterpreter("and_model_for_hx_only.tflite");
tflite.run(input, output);

// 마지막 매개변수는 button_3에서만 사용.
TextView tv_output = findViewById(R.id.tv_output);
tv_output.setText(makeOutputText("hx single", output, null));
} catch (Exception e) {
e.printStackTrace();
}
}
});

// hx 연산 1개를 다중 출력으로 처리 (and_model_for_hx_only.tflite 파일)
findViewById(R.id.button_2).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
try {
float[][][] input = new float[][][]{{{0}, {0}}, {{0}, {1}}, {{1}, {0}}, {{1}, {1}}};
float[][] output = new float[][]{{0}, {0}, {0}, {0}};

// 3차원이 여러 개 있어야 하니까 4차원
float[][][][] inputs = new float[][][][]{input};

// 출력이 1개라서 요소도 1개만 넣는다.
java.util.Map<Integer, Object> outputs = new java.util.HashMap();
outputs.put(0, output);

Interpreter tflite = getTfliteInterpreter("and_model_for_hx_only.tflite");
tflite.runForMultipleInputsOutputs(inputs, outputs);

TextView tv_output = findViewById(R.id.tv_output);
tv_output.setText(makeOutputText("hx multi", output, null));
} catch (Exception e) {
e.printStackTrace();
}
}
});

// hx와 logics 연산 2개 처리 (and_model_for_hx_logics.tflite 파일)
findViewById(R.id.button_3).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
try {
// 자료형을 Object로 바꾸면 좋은데 makeOutputText 함수를 구성하기 어려워져서 생략.
float[][][] input = new float[][][]{{{0}, {0}}, {{0}, {1}}, {{1}, {0}}, {{1}, {1}}};
float[][] output_1 = new float[][]{{0}, {0}, {0}, {0}};
int[][] output_2 = new int[][]{{-1}, {-1}, {-1}, {-1}};

// 4차원은 복잡하니까 float[][][]를 Object 타입으로 처리하면 간편.
Object[] inputs = new Object[]{input};

// 출력 연산 개수에 맞게 출력 배열 추가 (hx와 logics 2개)
java.util.Map<Integer, Object> outputs = new java.util.HashMap();
outputs.put(0, output_1);
outputs.put(1, output_2);

// 이전 함수와 모델 파일이 달라졌다.
Interpreter tflite = getTfliteInterpreter("and_model_for_hx_logics.tflite");
tflite.runForMultipleInputsOutputs(inputs, outputs);

// makeOutputText 함수의 마지막 매개변수는 여기서만 사용. 이전 함수는 출력 결과가 하나뿐.
TextView tv_output = findViewById(R.id.tv_output);
tv_output.setText(makeOutputText("hx + logics", output_1, output_2));
} catch (Exception e) {
e.printStackTrace();
}
}
});
}

// output은 4행 1열. 4행이라는 것은 4개의 데이터에 대해 예측했다는 뜻.
private String makeOutputText(String title, float[][] output_1, int[][] output_2) {
String result = title + "\n";
for (int i = 0; i < output_1.length; i++)
result += String.valueOf(output_1[i][0]) + " : ";

// button_3에서만 사용하는 코드. 출력 2개는 button_3에만 있다.
if(output_2 != null) {
result += "\n";
for (int i = 0; i < output_2.length; i++)
result += String.valueOf(output_2[i][0]) + " : ";
}

return result;
}

private Interpreter getTfliteInterpreter(String modelPath) {
try {
return new Interpreter(loadModelFile(MainActivity.this, modelPath));
}
catch (Exception e) {
e.printStackTrace();
}
return null;
}

private MappedByteBuffer loadModelFile(Activity activity, String modelPath) throws IOException {
AssetFileDescriptor fileDescriptor = activity.getAssets().openFd(modelPath);
FileInputStream inputStream = new FileInputStream(fileDescriptor.getFileDescriptor());
FileChannel fileChannel = inputStream.getChannel();
long startOffset = fileDescriptor.getStartOffset();
long declaredLength = fileDescriptor.getDeclaredLength();
return fileChannel.map(FileChannel.MapMode.READ_ONLY, startOffset, declaredLength);
}
}


텐서플로 라이트는 왜 여러 개의 연산을 처리할 수 있는 쉬운 코드를 제공하지 않았을까?
내가 내린 결론은 의미가 없어서.

모델이라고 하는 것은
특정한 기능 하나를 수행하는 것을 전제로 한다.
회귀도 하고, 분류도 할 수 있는 모델이 의미가 있을까?
정답은 연속된 숫자 아니면 범주형 데이터이지.. 두 가지 모두가 될 수는 없다.

하나의 모델에 회귀와 분류를 같이 넣을 수 있을까?
있다!
학습을 각각 시키고 각각의 기능을 제공하는 연산을 출력으로 전달하면 된다.
그러나, 이렇게 했을 때
회귀 연산만을 추출해서 사용할 수 있는 방법은 없다.
안드로이드 API를 모두 살펴봤지만 그런 함수는 존재하지 않았다.
결국은 출력에 전달된 모든 연산을 수행하고 나서
필요한 출력 결과만 사용해야 하는데.. 너무 바보같은 짓이 되고 만다.

그렇지만 안드로이드 앱에서는 두 가지 기능을 모두 제공할 필요가 있다.
이걸 쉽게 처리하는 방법은
이번 예제에서처럼 회귀 모델과 분류 모델을 각각 제공하고
눌린 버튼에 따라 다른 모델을 사용하면 된다.
인터페이스도 깔끔해지고 사용하는 개발자도 쉽다.

에고.. 힘들었다.
논리 연산에 대한 원본 소스는 아래 사이트에서 가져왔다.
케라스 버전으로 되어 있는데, 함께 보면 좋겠다.

XOR 논리값 모델

XOR 안드로이드 앱

4. 텐서플로 라이트 : 안드로이드 + AND 모델(텐서플로) (1)

앞에서 안드로이드와 연동하는 기초를 꼼꼼하게 챙겼다고 생각했다.
너무 쉽게 생각한 것 아닌가 하는 생각이 들었다.

여기서는 AND 논리 연산을 모델로 꾸며서 안드로이드와 연동한다.
FC 레이어를 사용하는 진짜 딥러닝 모델이기 때문에
이번 예제를 처리할 수 있다면
그 어떤 모델이라도 연동할 수 있다고 생각한다.
그렇지만..
이번에도 나만의 착각일 수 있다.

이번 예제를 통해 공부하려고 했던 것은
모델에 여러 개의 연산이 존재할 때 안드로이드에서 개별적인 호출이 가능한지, 였다.
결론부터 말하자면
할 수는 있지만, 그래야 할 이유가 전혀 없다.

최종적인 출력은 다음과 같다.
얼핏 보면 같아보일 수 있는데.. 각각 해당 버튼을 누른 결과이다.
차이를 주기 위해 상단 제목을 모두 다르게 처리했다.


출력된 실수가 모두 같은 것은
학습이 끝난 모델에 동일한 데이터로 결과를 예측하기 때문이다.
마지막 화면은 시그모이드 연산과 시그모이드를 논리값으로 바꾸는 연산의 결과를 보여준다.

파이썬에서 모델 파일을 2개 만든다.
첫 번째는 출력이 하나일 때 사용하고, 두 번째는 출력이 두 개일 때 사용한다.
이번 코드에서 중요한 것은 입력이 복잡해졌고, 두 번째 출력을 담당하는 logics 연산이다.

import tensorflow as tf
import numpy as np


def make_model(model_path, sess, inputs, outputs):
# inputs와 outputs가 여러 요소를 갖는다면 다중 입출력이 된다.
converter = tf.lite.TFLiteConverter.from_session(sess, inputs, outputs)
flat_data = converter.convert()

with open(model_path, 'wb') as f:
f.write(flat_data)


# and 논리값.
# [0, 0] => [0]
# [1, 1] => [1]
x = [[0, 0], [0, 1], [1, 0], [1, 1]] # np로 변환하지 않아도 됨
y = np.float32([[0], [0], [0], [1]]) # 변환하지 않으면 z와 타입 불일치 에러

# 2와 1을 변수로 대체하면 지저분해지기 때문에 생략
# n_features, n_classes = len(x[0]), len(y[0]) # 2, 1

holder_x = tf.placeholder(tf.float32, shape=[None, 2])

w = tf.Variable(np.random.rand(2, 1), dtype=tf.float32)
b = tf.Variable(np.random.rand(1), dtype=tf.float32)

# (4, 1) = (4, 2) @ (2, 1)
z = tf.matmul(holder_x, w) + b # fully connected 레이어
hx = tf.nn.sigmoid(z) # 활성 함수

# 정수 형변환. 텐서플로 연산이기 떄문에 모델에 포함되고, 안드로이드에서 사용할 수 있다.
# hx는 실수 배열, logics는 정수 배열. 일부러 출력 결과를 다르게 만들었다.
logics = tf.cast(hx >= 0.5, dtype=tf.int32)

loss = tf.nn.sigmoid_cross_entropy_with_logits(labels=y, logits=z)
train = tf.train.GradientDescentOptimizer(0.1).minimize(loss)

with tf.Session() as sess:
sess.run(tf.global_variables_initializer())

for i in range(100):
sess.run(train, {holder_x: x})

# print(sess.run([hx, logics], {holder_x: x}))
# hx : [[0.04933239], [0.22989444], [0.24103162], [0.64626104]],
# logics : [[0], [0], [0], [1]]

# 출력 1개인 모델과 2개인 모델을 별도 생성
make_model('and_model_for_hx_only.tflite', sess, [holder_x], [hx])
make_model('and_model_for_hx_logics.tflite', sess, [holder_x], [hx, logics])


make_model 함수를 만들어서
첫 번째는 [hx], 두 번째는 [hx, logics]를 전달했고 파일 이름을 다르게 처리했다.

이번 글은 여기까지 하고
안드로이드 프로젝트는 다음 글에서 만들어 본다.

3. 텐서플로 라이트 : 안드로이드 기초(3)

안드로이드 기초의 마지막 부분이다.
시뮬레이터에서 앱을 구동해서 모델과 연동하는 마지막 코드까지 간다.


8. tflite 모델 파일을 로딩하고 run 함수를 호출해서 결과를 가져온다.
MainActivity.java 파일을 연다.
안드로이드가 익숙하지 않다면 아래 화면이 도움이 될 것이다. ^^


별 내용 없다.
파일 전체를 아래 코드로 대체한다.
붙여넣기를 할 때, 꼭대기에 패키지 이름이 있는데
여기서 설명하는 이름과 같지 않다면 첫 번째 줄은 복사하지 않는다.

package com.example.simplelite;

import android.app.Activity;
import android.content.res.AssetFileDescriptor;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.TextView;

import org.tensorflow.lite.Interpreter; // 핵심 모듈

import java.io.FileInputStream;
import java.io.IOException;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;

public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

// xml 파일에 정의된 TextView 객체 얻기
final TextView tv_output = findViewById(R.id.tv_output);

// R.id.button_1 : 첫 번째 버튼을 가리키는 id
// setOnClickListener : 버튼이 눌렸을 때 호출될 함수 설정
findViewById(R.id.button_1).setOnClickListener(new View.OnClickListener() {
// 리스너의 기능 중에서 클릭(single touch) 사용
@Override
public void onClick(View v) {
// input : 텐서플로 모델의 placeholder에 전달할 데이터(3)
// output: 텐서플로 모델로부터 결과를 넘겨받을 배열. 덮어쓰기 때문에 초기값은 의미없다.
int[] input = new int[]{3};
int[] output = new int[]{0}; // 15 = 3 * 5, out = x * 5

// 1번 모델을 해석할 인터프리터 생성
Interpreter tflite = getTfliteInterpreter("simple_1.tflite");

// 모델 구동.
// 정확하게는 from_session 함수의 output_tensors 매개변수에 전달된 연산 호출
tflite.run(input, output);

// 출력을 배열에 저장하기 때문에 0번째 요소를 가져와서 문자열로 변환
tv_output.setText(String.valueOf(output[0]));
}
});

findViewById(R.id.button_2).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// 입력 데이터 2개 사용. [][]는 2차원 배열을 의미한다.
int[][] input = new int[][]{{3}, {7}};
int[] output = new int[]{0}; // 58 = (3 * 3) + (7 * 7), out = sum(x * x)

Interpreter tflite = getTfliteInterpreter("simple_2.tflite");
tflite.run(input, output);

tv_output.setText(String.valueOf(output[0]));

// 아래 코드는 에러.
// 텐서플로의 벡터 연산을 자바쪽에서 풀어서 계산해야 하는데,
// 구성 요소가 객체 형태로 존재하지 않을 경우 shape이 일치하지 않아서 발생하는 에러
// int[] input = new int[]{3, 7};
//
// 모델을 구성할 때 사용한 코드. x * x는 배열간의 연산이다.
// x = tf.placeholder(tf.int32, shape=[2])
// out = tf.reduce_sum(x * x)
}
});

findViewById(R.id.button_3).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// 입력 변수를 별도로 2개 구성
int[] input_1 = new int[]{3};
int[] input_2 = new int[]{7};
int[][] inputs = new int[][]{input_1, input_2};

// 출력은 하나지만, 함수 매개변수를 맞추기 위해 맵 생성
java.util.Map<Integer, Object> outputs = new java.util.HashMap();

// 출력을 받아올 변수 1개 추가
int[] output_1 = new int[]{0}; // 10 = 3 + 7, out = x + y
outputs.put(0, output_1);

Interpreter tflite = getTfliteInterpreter("simple_3.tflite");

// 구동 함수는 run과 지금 이 함수밖에 없다.
// runForMultipleInputsOutputs 함수는 입력도 여럿, 출력도 여럿이다.
// 입력은 입력들의 배열, 출력은 <Integer, Object> 형태의 Map.
// key와 value에 해당하는 Integer와 Object 자료형은 변경할 수 없다.
tflite.runForMultipleInputsOutputs(inputs, outputs);

tv_output.setText(String.valueOf(output_1[0]));
}
});

findViewById(R.id.button_4).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// 변수 2개를 전달하는 방법으로 앞에서처럼 해도 되지만 이번 코드가 간결하다.
int[][] inputs = new int[][]{{3}, {7}};

java.util.Map<Integer, Object> outputs = new java.util.HashMap();

// 별도 변수없이 직접 put 함수에 전달하면서 배열 생성
outputs.put(0, new int[]{0}); // 10, 21 = 3 + 7, 3 * 7 : out1, out2 = x + x, y * y
outputs.put(1, new int[]{0});

Interpreter tflite = getTfliteInterpreter("simple_4.tflite");
tflite.runForMultipleInputsOutputs(inputs, outputs);

// 별도로 출력 변수를 정의하지 않았기 때문에 Map 클래스의 get 함수를 통해 가져온다.
// Object 자료형을 배열로 변환해서 사용
int[] output_1 = (int[]) outputs.get(0);
int[] output_2 = (int[]) outputs.get(1);
tv_output.setText(String.valueOf(output_1[0]) + " : " + String.valueOf(output_2[0]));
}
});
}

// 모델 파일 인터프리터를 생성하는 공통 함수
// loadModelFile 함수에 예외가 포함되어 있기 때문에 반드시 try, catch 블록이 필요하다.
private Interpreter getTfliteInterpreter(String modelPath) {
try {
return new Interpreter(loadModelFile(MainActivity.this, modelPath));
}
catch (Exception e) {
e.printStackTrace();
}
return null;
}

// 모델을 읽어오는 함수로, 텐서플로 라이트 홈페이지에 있다.
// MappedByteBuffer 바이트 버퍼를 Interpreter 객체에 전달하면 모델 해석을 할 수 있다.
private MappedByteBuffer loadModelFile(Activity activity, String modelPath) throws IOException {
AssetFileDescriptor fileDescriptor = activity.getAssets().openFd(modelPath);
FileInputStream inputStream = new FileInputStream(fileDescriptor.getFileDescriptor());
FileChannel fileChannel = inputStream.getChannel();
long startOffset = fileDescriptor.getStartOffset();
long declaredLength = fileDescriptor.getDeclaredLength();
return fileChannel.map(FileChannel.MapMode.READ_ONLY, startOffset, declaredLength);
}
}


9. 안드로이드 화면을 구성하고 코드를 추가해서 결과를 표시한다.
화면 구성은 xml 파일에서 한다.
layout 폴더에서 activity_main.xml 파일을 연다.
아래와 같이 "Hello World"가 출력되는 단순한 미리보기가 나타난다.


아래 코드 전체를 activity_main.xml 파일에 덮어쓴다.
레이아웃과 컴포넌트는 안드로이드를 몰라도 대충 이해할 수 있기 때문에 설명은 생략한다.

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical">

<TextView
android:id="@+id/tv_output"
android:text="결과 출력"
android:textSize="23dp"
android:gravity="center"
android:layout_width="match_parent"
android:layout_height="100dp" />

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">

<Button
android:id="@+id/button_1"
android:text="입력 1\n출력 1"
android:textSize="19dp"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="100dp" />

<Button
android:id="@+id/button_2"
android:text="입력 2\n출력 1"
android:textSize="19dp"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="100dp" />

<Button
android:id="@+id/button_3"
android:text="입력 1+1\n출력 1"
android:textSize="19dp"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="100dp" />

<Button
android:id="@+id/button_4"
android:text="입력 1+1\n출력 1+1"
android:textSize="19dp"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="100dp" />

</LinearLayout>
</LinearLayout>
</FrameLayout>


마지막으로 구동하고 나면
아래와 같은 최종 화면을 얻게 되고, 버튼을 누를 때마다 학습한 결과를 볼 수 있을 것이다.


2. 텐서플로 라이트 : 안드로이드 기초(2)

파이썬으로 해야 할 작업은 끝났다.
원래는 시행착오를 거쳐야 하기 때문에
파이썬과 안드로이드를 왔다갔다 하면서 짜증이 엄청 나야 한다.

이번에 할 작업은 안드로이트 프로젝트 구현 전반부이다.
안드로이드 프로젝트 생성부터 gradle 파일 수정까지 진행한다.

3. 안드로이드 프로젝트를 생성한다.
비어있는 프로젝트를 하나 만든다.


프로젝트 이름은 SimpleLite으로 한다.
프로젝트 폴더에 한글이 포함되면 경고가 뜬다. 이름만 입력하고 나머지는 그대로 둔다.

4. assets 폴더를 만들고 모델 파일을 붙여넣는다.
프로젝트를 만들면 assets 폴더가 존재하지 않는다.
폴더가 너무 많아서 위치를 정확하게 잘 찾아야 한다.
왼쪽 상단에 Project가 열려있는 것을 볼 수 있는데, 처음에는 Android라고 되어 있다.
Project로 변경하면 아래 화면을 볼 수 있고 main 폴더까지 가서 만들어야 한다.

이전 글에서 만든 4개의 모델 파일을 assets 폴더로 복사한다.


5. 텐서플로 라이트 모듈을 사용할 수 있도록 gradle 파일에 내용을 추가한다.

gradle 파일을 수정해야 하는데
그림에서 보는 것처럼 build.gradle 파일이 두 가지가 있다.
아래에 있는 Module 버전을 수정한다.


파일을 열어보면 dependencies 영역이 나오는데
텐서플로 라이트 모듈을 마지막에 있는 것처럼 추가한다.

dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'com.android.support:appcompat-v7:28.0.0'
implementation 'com.android.support.constraint:constraint-layout:1.1.3'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'

implementation 'org.tensorflow:tensorflow-lite:+'
}


6. gradle 파일을 수정해서 모델 파일이 압축되지 않도록 한다.
android 영역을 찾아서 마지막에 aaptOptions를 추가한다.
제목에 있는 것처럼 압축을 방지한다.
메모리를 절약하기 위해 리소스를 압축하는데 그럴 경우 모델을 올바로 읽어들일 수가 없다.

android {
... 생략
aaptOptions {
noCompress "tflite"
}
}


7. gradle 파일을 수정했으니까 동기화를 진행한다.
파일을 수정하게 되면 파일 오른쪽 상단에 [Sync Now] 메뉴가 나타난다.
눌러주면 동기화를 진행하고 문제없으면 에러가 표시되지 않는다.

이번 글은 여기까지.

1. 텐서플로 라이트 : 안드로이드 기초(1)

첫 번째로 모델이라고 부를 수도 없는 모델을 안드로이드와 연동한다.
모델이 됐건 연산이 됐건
텐서플로 라이트 입장에선 다를 것이 없다.
너무 간단한 덧셈과 곱셈 연산을 통해
안드로이드로부터 플레이스 홀더 입력을 받아오는 것부터 해보자.

내용이 길어서 여러 개로 나누어서 작업한다.
여기서는 텐서플로 모델을 생성하고 tflite 파일로 변환하는 것까지 진행한다.

  1. PC에서 모델을 학습한다.
  2. 학습한 모델을 텐서플로 라이트 버전으로 변환한다.

안드로이드 앱을 구성하고 나면
최종적으로 아래와 같은 화면이 뜬다.
다양한 입력을 보여주기 위해 4가지 형태로 구성했다.
4개의 버튼 중에서 하나를 누르면 상단에 있는 텍스트뷰에 결과를 보여준다.


코드가 조금 긴데..
4가지 코드를 한번에 보여주기 때문에 그런 것뿐.. 하나하나는 가볍기 그지 없다.
model_common 함수를 중점적으로 봐야 하고
나머지 함수들에서는 입력과 출력이 어떻게 달라지는지를 봐야 한다.

import tensorflow as tf


# 이번 파일에서 공통으로 사용하는 함수.
# 컨버터 생성해서 파일로 저장. 다시 말해 모바일에서 사용할 수 있는 형태로 변환해서 저장.
def model_common(inputs, outputs, model_path):
# 텐서플로 API만을 사용해서 저장할 수 있음을 보여준다.
# 4가지 방법 중에서 가장 기본.
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())

# input_tensors: List of input tensors. Type and shape are computed using get_shape() and dtype.
# output_tensors: List of output tensors (only .name is used from this).
converter = tf.lite.TFLiteConverter.from_session(sess,
input_tensors=inputs,
output_tensors=outputs)
# 세션에 들어있는 모든 연산, 즉 모델 전체를 변환
# 반환값은 TFLite 형식의 Flatbuffer 또는 Graphviz 그래프
flat_data = converter.convert()

# 텍스트가 아니기 때문에 바이너리 형태로 저장. w(write), b(binary)
with open(model_path, 'wb') as f:
f.write(flat_data)


# 입력 1개, 출력 1개
def simple_model_1(model_path):
# 에러. 반드시 shape을 지정해야 함.
# x = tf.placeholder(tf.int32)

# 안드로이드에서 전달한 입력과 출력 변수가 플레이스 홀더와 연동
x = tf.placeholder(tf.int32, shape=[1])
out = x * 5

model_common([x], [out], model_path)

# 에러. 반드시 [] 형태로 전달해야 함.
# model_common(x, out, model_path)


# 입력 2개짜리 1개, 출력 1개
def simple_model_2(model_path):
x = tf.placeholder(tf.int32, shape=[2])
out = tf.reduce_sum(x * x)

model_common([x], [out], model_path)


# 입력 1개짜리 2개, 출력 1개
def simple_model_3(model_path):
# 에러. 반드시 shape을 지정해야 함.
# x1 = tf.placeholder(tf.int32, shape=[0])
# x2 = tf.placeholder(tf.int32, shape=[0])

x1 = tf.placeholder(tf.int32, shape=[1])
x2 = tf.placeholder(tf.int32, shape=[1])
out = tf.add(x1, x2)

# 입력에 2개 전달
model_common([x1, x2], [out], model_path)


# 입력 1개짜리 2개, 출력 1개짜리 2개
def simple_model_4(model_path):
x1 = tf.placeholder(tf.int32, shape=[1])
x2 = tf.placeholder(tf.int32, shape=[1])
out_1 = x1 + x2
out_2 = x1 * x2

# 입력에 2개, 출력에 2개 전달
model_common([x1, x2], [out_1, out_2], model_path)


simple_model_1('simple_1.tflite')
simple_model_2('simple_2.tflite')
simple_model_3('simple_3.tflite')
simple_model_4('simple_4.tflite')


tflite 파일로 변환하는 방법에는 4가지가 있는데
나는 쉬운 것만 쓰고 좀더 나을 수도 있겠지만 복잡해 보이는 방법은 사용하지 않을 것이다.

여기서는 가장 기본이 되는 텐서플로 코드를 직접 변환하는 방법을 사용했다.
세션에 들어있는 모든 데이터와 연산을 변환하게 되고 함수 이름은 from_session이다.
다른 방법이 궁금하면
도트(.)를 찍고 나면 어떤 방법이 있는지 이름을 통해 확인할 수 있다.

위의 코드를 실행하고 나면
현재 폴더에 tflite 파일 4개가 만들어진다.
생성된 모든 파일은 뒤에서 안드로이드 프로젝트로 복사해서 붙여넣는다.

PC 버전의 모델을 tflite 모델로 변환한다는 것은
모바일 버전에서 효율적으로 동작할 수 있도록 재구성하는 것이다.
PC 버전을 살짝 바꿔서 모바일 버전을 구현할 수도 있겠지만
그럴 경우 하드웨어 사양이 낮은 모바일에서는 결과 보기가 매우 힘들어진다.

convert 함수를 호출하면 모델을 FlatBuffer로 변환한다.
구글에서 게임 개발에 사용하기 위해 만들었는데
크로스 플랫폼에서 데이터를 효율적으로 처리하기 위한 라이브러리로 사용된다.



0. 텐서플로 라이트 : 소개

딥러닝을 강의하면서
꼭 해야지 하고 생각했던 것이 모바일과의 연동이었다.
정확하게는 스마트폰.
아이폰과 안드로이드 프로그래밍을 주로 했었으니까
딥러닝 모델을 모바일에 얹지 않고는 견디기 어려웠다.
놀기 바빠서 혹은 할게 많아서
이제서야 정리를 하게 됐다.
플러터를 다 하진 못했지만 어느 정도는 정리했으니까.
때가 됐다.

참고할 만한 자료가 많지 않아서 고생했다.
텐서플로에서 공개한 기본적인 내용만 소개하는 곳이 많았고
내가 원하는 모델을 올릴 수 있는 방법에 대한 설명은 찾지 못했다.
어쩌다 영문 사이트로부터 단서를 얻었고
그로부터 획득한 추가 정보들을 정리해 보려 한다.

텐서플로 라이트 공식 홈페이지가 있으니까 한번 가봐야 하지 않겠는가!


텐서플로 라이트는 텐서플로 모델을 모바일 환경에서 구동하도록 해준다.
모델 학습을 모바일에서 직접 하는 것은 아니고
학습된 모델을 모바일에 올려서 예측할 수 있도록 지원한다.

작업 순서는 다음과 같다.
여기서는 안드로이드 버전으로 진행하고 플러터 버전은 따로 보충한다.

  1. PC에서 모델을 학습한다.
  2. 학습한 모델을 텐서플로 라이트 버전으로 변환한다.
  3. 안드로이드 프로젝트를 생성한다.
  4. assets 폴더를 만들고 모델 파일을 붙여넣는다.
  5. 텐서플로 라이트 모듈을 사용할 수 있도록 gradle 파일에 내용을 추가한다.
  6. gradle 파일을 수정해서 모델 파일이 압축되지 않도록 한다.
  7. gradle 파일을 수정했으니까 동기화를 진행한다.
  8. tflite 모델 파일을 로딩하고 run 함수를 호출해서 결과를 가져온다.
  9. 안드로이드 화면을 구성하고 코드를 추가해서 결과를 표시한다.

역시.. 머리 속에 있을 때와는 달리
쓰고 나니까.. 순서가 꽤나 길다.
순서는 길지만 실제로는 어렵지 않다.

그래도 이 글을 읽는 사람은 알았으면 한다.
쉽게 정리하기까지 오래 걸렸다.

준비됐으면.. 가 보자!!