バイセル Tech Blog

バイセル Tech Blogは株式会社BuySellTechnologiesのエンジニア達が知見・発見を共有する技術ブログです。

TensorFlow Servingを用いたKubernetesでのObject Detection APIの構築

テクノロジー戦略本部の村上です。以前Object Detectionを試した結果を紹介させて頂きましたが、 今回はその方法で生成したモデルを使って、TensorFlow Servingを用いてAPIを作成したのでその紹介をしたいと思います。 なお、現在検証中でリリース前ですので、紹介した内容から変更が入るかもしれないのでご了承下さい。

tech.buysell-technologies.com

TensorFlow Servingとは

TensorFlow Servingは、SavedModelというフォーマットで書き出したモデルを読み込んで、RESTとgRPCの両方で推論APIを提供出来るシステムです。フォーマットさえ合っていればモデルの内容に関係なく使用出来るという汎用性が特徴で、全く実装をせずにモデルさえ与えればAPIサーバーとして動きます。Googleの記事を見ますと、Googleでは2017年時点で800以上のプロジェクトで本番環境で使われているようです。

使用する上での注意点としては、

  1. モデルを与えるだけなので自前でモデルを実装した場合、入力データの前処理などもモデルに含めた方が良い
  2. 汎用化するためにリクエストとレスポンスが汎用的なフォーマットなので、APIを呼び出すときとレスポンスを使う際に変換が必要となる

があります。1は例えば画像を入力にしたい場合はモデルに前処理などを含めないとAPIとしては使いづらいものになってしまいます。 以下のTensorFlowの公式にも同様のことが書かれています。

Benefits of doing preprocessing inside the model at inference time

2のデータフォーマットですが、たとえばpredictionのAPIのレスポンスのProtocolBufferの定義を見ますと以下のようになっています。

message PredictResponse {
  ModelSpec model_spec = 2;
  map<string, TensorProto> outputs = 1;
}

outputsフィールドはmapになっているのでここでまず汎用的になっています。 さらにmapのvalueの型であるTensorProtoの定義を見てみると、このようになっています。

message TensorProto {
  DataType dtype = 1;

  TensorShapeProto tensor_shape = 2;
  int32 version_number = 3;

  bytes tensor_content = 4;
  repeated int32 half_val = 13 [packed = true];
  repeated float float_val = 5 [packed = true];
  repeated double double_val = 6 [packed = true];
  // 省略
  repeated uint64 uint64_val = 17 [packed = true];
}

このように、型の種類を指定して、その型に該当するフィールドに値を入れるという形になっています。 ですのでどんな型でも対応出来る代わりに、自分で定義する場合と違いフィールド名に意味を持たせたり階層構造を作ったりが出来ないというデメリットがあります。

システム実装

構成

上で述べたように汎用的であり、特にObject Detectionはレスポンスの内容が多いのでTensorFlow Servingをそのまま使うのは難しいです。そこで、以下のように構成にしました。

f:id:nmu0:20201219034900j:plain

図のKubernetesに載せている部分が今回開発した部分です。APIとしては画像を入力にしてDetection結果を出力としたいので、インピーダンスマッチングをするアプリケーションを用意しました。 どの矢印もgRPCで通信しています。gRPCを使うためのIstioが導入されたKubernetesは、既に弊社で運用しているものを使用しています。

Tensorflow Servingのデプロイ

まずモデル生成ですが、Object Detectionの学習済みモデルは、以下のコマンドでSavedModelフォーマットに変換出来ます。

python ./exporter_main_v2.py --input_type image_tensor --pipeline_config_path ./models/my_efficientdet_d1/pipeline.config --trained_checkpoint_dir ./models/my_efficientdet_d1/ --output_directory ./exported-models/my_model

そのモデルをどう組み込むかは何通りか考えられますが、今回はGitHub ActionsでモデルをGCSから取得し、 それをDockerイメージに組み込む方法を取りました。ですので以下のようなDockerfileを用意してデプロイすることとなります。 COPYしているのがモデルとなります。app_nameという名前はあくまでこの記事用の例ですが、環境変数のMODEL_NAMEと合わせる必要があります。

FROM tensorflow/serving:2.3.0

COPY app_name /models/app_name
ENV MODEL_NAME=app_name

これをKubernetesにデプロイする際は、以下のKubeflowのページに掲載されているマニフェストを参考にすれば良いです。

TensorFlow Serving

API実装

まずAPIのメインの役割であるインピーダンスマッチングの部分を説明します。前述したようにTensorFlow Servingの入出力が汎用化されているので、それぞれを表すキー名をまず把握する必要があります。これは実装を見るしかないです。 それぞれこのようになっていました。

  1. 入力: input_tensor
  2. 出力: detection_boxes, detection_scores, detection_classes, num_detections

キー名がわかったので、あとはその形式で入出力を変換するだけです。

入力の変換は以下のようになります。input_tensorの準備は公式の例のrun_inference_for_single_imageというメソッドを参考にしました。

def generate_request(image_np):
    tensor = tf.convert_to_tensor(image_np)
    input_tensor = tensor[tf.newaxis, ...]
    request = predict_pb2.PredictRequest()
    request.model_spec.name = 'app_name'
    tensor_proto = tf.make_tensor_proto(input_tensor, shape=input_tensor.shape)
    request.inputs['input_tensor'].CopyFrom(tensor_proto)
    return request

出力の変換は以下のようになります。スコアが良い順に格納されているので、今回は一番良いスコアの結果を抽出しました。

class Result(typing.NamedTuple):
    class_index: int
    score: float
    rect: Rect

class Rect(typing.NamedTuple):
    x_min: int
    y_min: int
    x_max: int
    y_max: int

    @classmethod
    def init(cls, x, y, box):
        return cls(y_min=int(y * box[0]),
                   x_min=int(x * box[1]),
                   y_max=int(y * box[2]),
                   x_max=int(x * box[3]))


detection_classes = np.array(result.outputs['detection_classes'].float_val)
detection_scores = np.array(result.outputs['detection_scores'].float_val)
detection_boxes = np.array(result.outputs['detection_boxes'].float_val).reshape([100, 4])
box = detection_boxes[0]
rect = Rect.init(x, y, box)
Result(class_index=int(detection_classes[0]), score=detection_scores[0], rect=rect)

リクエストが準備できればあとはTensorFlow ServingのgRPC APIを呼び出すだけです。呼び出す時の注意点としては、単にホストを設定するだけでは以下のIssueに書かれているようなエラーが出るという点です。

gRPC: Received message larger than max (32243844 vs. 4194304)" #1382

そこで書かれているようにmax_send_message_lengthmax_receive_message_lengthを設定すると良いです。このようになります。

from tensorflow_serving.apis import prediction_service_pb2_grpc
channel_opt = [('grpc.max_send_message_length', 512 * 1024 * 1024),
                ('grpc.max_receive_message_length', 512 * 1024 * 1024)]
channel = grpc.insecure_channel('localhost:8500', options=channel_opt)
stub = prediction_service_pb2_grpc.PredictionServiceStub(channel)
result = stub.Predict(generate_request(image_np), RPC_TIMEOUT)

これで画像のndarrayを変換してTensorFlow ServingのAPIを呼び出し、その結果から必要な部分の抽出が出来たこととなります。 あとはそれをクライアントに返すだけです。

まとめ

いかがでしたでしょうか。記事で書いたようにモデルさえ作成できればTensorFlow Servingで簡単にAPIを実装することが出来ます。 公式リポジトリに色々とサンプルはあるものの、あまりまとまった記事は見当たらなかった記憶があるので、少しでも参考になれば幸いです。

参考資料