텐서플로(TensorFlow)를 활용한 딥러닝 기반 객체 인식 앱 개발

안녕하세요. 이번 시간에는 플러터(Flutter)로 텐서플로(TensorFlow)를 활용해서 딥러닝 기반의 실시간 객체(이미지) 인식이 가능한 안드로이드 앱을 만들어볼 거예요. 카메라를 통해 비치는 실시간 영상을 분석 후 객체를 인식하고 분류하도록 할게요. 즉 사진을 찍지 않아도 실시간으로 화면에 나타나는 영상을 기반으로 객체 인식이 이루어집니다.

 

이미지 인식은 선행 학습된 인공지능 딥러닝 모델인 모바일넷(MobileNet v1)을 사용해서 실시간으로 사물 및 사람을 인식하고 분류합니다. 궁금하신 분들을 위해 오늘 만들어 볼 앱의 주요 화면을 먼저 보고 갈게요.

 

TensorFlow 객체 인식 예제

텐서플로우 실시간객체 인식1텐서플로우 실시간객체 인식2텐서플로우 실시간객체 인식3

딥러닝 기반의 객체 인식 모델(예: 모바일넷)은 이미지에 있는 다양한 물체를 감지하고 분류할 때 각 물체에 대한 예측 확률을 계산해요. 예를 들어 이미지에서 강아지와 고양이를 인식하는 경우 모델은 강아지일 확률과 고양이일 확률을 계산하게 되죠. 이 예측 확률은 'score'로 표현되며 일반적으로 0과 1 사이의 값으로 나타내는데요. 값이 1에 가까울수록 해당 객체의 예측 확률이 높다는 것을 의미합니다.

 

따라서 'score'는 모델이 예측한 결과의 신뢰도를 나타내는 지표로 사용돼요. 높은 'score' 값을 가진 결과일수록 모델이 해당 객체를 정확하게 인식했다고 판단할 확률이 높아요. 오늘 예제에서는 'score'를 백분율로 변환해서 확률(%)로 표현했습니다.

 

이번에는 비슷한 물체들을 사용해서 테스트를 진행하고, 추가로 노트북 화면에 있는 영상을 통해서도 시도해 볼게요.

텐서플로우 실시간객체 인식4텐서플로우 실시간객체 인식5텐서플로우 실시간객체 인식6

물체와의 거리가 가까워질수록 예측 확률이 증가하네요! 마지막으로 인파가 많은 장소에서도 시도해 볼게요. 참고로 성능 최적화를 위해 동시에 인식 가능한 객체의 개수를 최대 10개로 제한했기 때문에 그 이상은 인식을 하지 못합니다.

텐서플로우 실시간객체 인식7텐서플로우 실시간객체 인식8텐서플로우 실시간객체 인식8

지하철역과 길거리에서 각각 실시간 감지를 해보았는데요. 지하철역에서는 스크린도어가 있음에도 불구하고 전철까지 인식하는 것을 확인할 수 있었고, 길거리의 빌딩에서는 유리로 비치는 물체들까지도 감지하는 모습을 볼 수 있었습니다.

 


텐서플로 - TensorFlow

구글에서 개발한 머신러닝 라이브러리예요. 컴퓨터가 스스로 학습하는 방법, 즉 머신러닝 및 딥러닝 할 때 사용하는 도구로 컴퓨터가 사진, 글, 음성 등 다양한 정보를 학습하고 이를 바탕으로 새로운 정보를 예측할 수 있게 도와줍니다. 이 라이브러리를 사용하면 다양한 머신러닝 모델을 개발하고 학습시킬 수 있어요. 참고로 텐서플로우 자체로는 이미지 분석을 바로 수행할 수 없고 텐서플로우를 사용해서 모델을 설계하고 학습시켜야 합니다.

텐서플로우 라이트 - TensorFlow Lite

텐서플로우의 경량화 버전으로, 모바일 및 임베디드 기기에서 머신러닝 및 딥러닝 모델을 실행하는 데 최적화되어 있어요. 이를 통해 모바일넷과 같은 선행 학습된 모델을 모바일 기기에서도 사용할 수 있게 됩니다.

 

모바일넷 - MobileNet

선행 학습된 딥러닝 모델로 이미지 인식과 관련된 기능을 수행할 수 있어요. 텐서플로우 라이트는 이 모바일넷 모델을 모바일 기기에서 실행할 수 있게 해주는 라이브러리인 거죠. 모바일 기기에서 실시간으로 이미지를 분석할 때, 속도와 정확도가 모두 중요한데 모바일넷은 작은 모델 크기와 낮은 연산 비용을 유지하면서도 높은 정확도를 보장해요.

 

앱 개발 시작

자! 이제 앱을 만들기 전에 몇 가지 준비 작업을 해야 돼요. 아래의 순서에 따라 작업을 진행해 주세요.

 

1. 앱 개발에 필요한 패키지들을 pubspec.yaml 파일에 추가합니다.

  • camera: 모바일 앱에서 카메라와 관련된 기능을 쉽게 사용하기 위한 라이브러리입니다.
  • tflite_flutter: TensorFlow Lite를 사용하여 머신러닝 모델을 Flutter 앱에 통합하는 데 사용되는 라이브러리입니다.
  • tflite_flutter_helper: TensorFlow Lite를 사용하는데 필요한 헬퍼 클래스와 함수를 제공하는 라이브러리입니다. 공식 버전은 호환이 안 돼서 GitHub의 수정된 버전을 참조했습니다.
  • image: 이미지 처리와 관련된 작업을 수행하는 데 사용되는 라이브러리입니다. 이미지 크기 변경, 회전, 잘라내기 등의 작업을 수행할 수 있습니다.
  camera: ^0.9.8+1
  tflite_flutter: ^0.9.1
  tflite_flutter_helper: ^0.3.1
  image: ^3.3.0

 

2. AndroidManifest.xml 파일 태그 안에 카메라 접근 권한을 추가해 주세요.

<application
   ...기존 코드
    <uses-permission android:name="android.permission.CAMERA" />
</application>

 

3. 배치파일을 pubspec.yaml 파일과 동일한 경로에 복사 후 윈도우 CMD나 안드로이드 스튜디오의 터미널에서 실행해 주세요. TensorFlow를 사용하는 Android 앱 개발에 필요한 라이브러리들이 프로젝트에 자동으로 추가됩니다.

install.bat
0.00MB

# Windows
install.bat -d
# Linux, Mac
sh install.sh -d

 

4. MobileNet v1 모델과 레이블을 다운로드하고 /assets에 복사한 후 pubspec.yaml에 해당 경로를 추가해 주세요.

labelmap.txt
0.00MB
detect.tflite
3.99MB

flutter:
  assets:
    - assets/

 

5. camera 패키지 0.9.8 버전부터는 최소 Android SDK 버전 21 이상 사용이 가능하기 때문에 android/app/build.gradle 파일에서 minSdkVersion 값을 수정해 줘야 합니다.

android { 
  ...기존코드
  defaultConfig { 
      ...기존코드
       minSdkVersion 21 
  } 
}

 


이렇게 사전 작업이 완료되었습니다. 이제부터 클래스를 하나씩 생성하면서 코드를 작성해 볼게요!

 

카메라의 프리뷰를 설정하는 CameraSettings 클래스

import 'dart:ui';

class CameraSettings {
  // 카메라 프리뷰의 가로 세로 비율을 저장하는 변수
  static double? ratio;
  // 화면 크기를 저장하는 변수
  static Size? screenSize;
  // 입력 이미지 크기를 저장하는 변수
  static Size? inputImageSize;
  // 실제 프리뷰 크기를 계산하여 반환하는 getter
  static Size get actualPreviewSize => Size(
    // 화면의 너비
    screenSize?.width ?? 0,
    // 화면의 너비에 비율을 곱하여 높이를 계산
    (screenSize?.width ?? 0) * (ratio ?? 0),
  );
}

CameraSettings 클래스는 카메라 프리뷰의 가로 세로 비율, 화면 크기 및 입력 이미지 크기를 저장하는 데 사용돼요. actualPreviewSize는 실제 프리뷰 크기를 계산하고 반환합니다.

이미지에서 객체를 인식하고 인식된 객체에 대한 정보를 반환하는 Classifier 클래스는 CameraSettings 클래스

import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:image/image.dart' as imageLib;
import 'package:untitled/services/recognition.dart';
import 'package:tflite_flutter/tflite_flutter.dart';
import 'package:tflite_flutter_helper/tflite_flutter_helper.dart';

class Classifier {
  // 인터프리터 및 레이블 저장
  Interpreter? _interpreter;
  List<String>? _labels;
  // 모델 및 레이블 파일 이름, 입력 크기, 임계값 정의
  static const String MODEL_FILE_NAME = "detect.tflite";
  static const String LABEL_FILE_NAME = "labelmap.txt";
  static const int INPUT_SIZE = 300;
  static const double THRESHOLD = 0.5;
  ImageProcessor? imageProcessor;
  int? padSize;
  List<List<int>>? _outputShapes;
  List<TfLiteType>? _outputTypes;
  static const int NUM_RESULTS = 10;
  // Classifier 클래스 생성자
  Classifier({
    Interpreter? interpreter,
    List<String>? labels,
  }) {
    loadModel(interpreter: interpreter);
    loadLabels(labels: labels);
  }
  // 머신러닝 모델 로드
  void loadModel({Interpreter? interpreter}) async {
    try {
      _interpreter = interpreter ??
          await Interpreter.fromAsset(
            MODEL_FILE_NAME,
            options: InterpreterOptions()..threads = 4,
          );
      var outputTensors = _interpreter?.getOutputTensors();
      _outputShapes = [];
      _outputTypes = [];
      outputTensors?.forEach((tensor) {
        _outputShapes?.add(tensor.shape);
        _outputTypes?.add(tensor.type);
      });
    } catch (e) {
      print(e);
    }
  }
  // 레이블 로드
  void loadLabels({List<String>? labels}) async {
    try {
      _labels =
          labels ?? await FileUtil.loadLabels("assets/" + LABEL_FILE_NAME);
    } catch (e) {
      print(e);
    }
  }
  // 입력 이미지를 처리
  TensorImage getProcessedImage(TensorImage? inputImage) {
    padSize = max(inputImage?.height ?? 0, inputImage?.width ?? 0);

    imageProcessor ??= ImageProcessorBuilder()
        .add(ResizeWithCropOrPadOp(padSize ?? 0, padSize ?? 0))
        .add(ResizeOp(INPUT_SIZE, INPUT_SIZE, ResizeMethod.BILINEAR))
        .build();
    inputImage = imageProcessor?.process(inputImage!);
    return inputImage!;
  }
  // 이미지를 예측하고 인식 결과를 반환
  Map<String, dynamic>? predict(imageLib.Image image) {
    if (_interpreter == null) {
      return null;
    }
    // 입력 이미지를 처리하고 예측
    TensorImage inputImage = TensorImage.fromImage(image);
    inputImage = getProcessedImage(inputImage);
    // 각 출력 텐서를 저장할 TensorBuffer 정의
    TensorBuffer outputLocations = TensorBufferFloat(_outputShapes![0]);
    TensorBuffer outputClasses = TensorBufferFloat(_outputShapes![1]);
    TensorBuffer outputScores = TensorBufferFloat(_outputShapes![2]);
    TensorBuffer numLocations = TensorBufferFloat(_outputShapes![3]);
    // 입력 이미지
    List<Object> inputs = [inputImage.buffer];
    // 출력에 해당하는 텐서 버퍼를 저장할 Map 정의
    Map<int, Object> outputs = {
      0: outputLocations.buffer,
      1: outputClasses.buffer,
      2: outputScores.buffer,
      3: numLocations.buffer,
    };
    // 머신러닝 모델 실행
    _interpreter?.runForMultipleInputs(inputs, outputs);
    int resultsCount = min(NUM_RESULTS, numLocations.getIntValue(0));
    int labelOffset = 1;
    // 인식된 객체의 위치 변환
    List<Rect> locations = BoundingBoxUtils.convert(
      tensor: outputLocations,
      valueIndex: [1, 0, 3, 2],
      boundingBoxAxis: 2,
      boundingBoxType: BoundingBoxType.BOUNDARIES,
      coordinateType: CoordinateType.RATIO,
      height: INPUT_SIZE,
      width: INPUT_SIZE,
    );
    // 인식 결과를 저장할 목록 정의
    List<Recognition> recognitions = [];
    // 각 인식 결과를 Recognition 객체로 생성하고 목록에 추가
    for (int i = 0; i < resultsCount; i++) {
      var score = outputScores.getDoubleValue(i);
      var labelIndex = outputClasses.getIntValue(i) + labelOffset;
      var label = _labels?.elementAt(labelIndex);
      if (score > THRESHOLD) {
        Rect? transformedRect = imageProcessor?.inverseTransformRect(
          locations[i],
          image.height,
          image.width,
        );

        recognitions.add(
          Recognition(i, label, score, transformedRect),
        );
      }
    }
    // 인식 결과 반환
    return {
      "recognitions": recognitions,
    };
  }
  // 인터프리터 및 레이블 getter
  Interpreter? get interpreter => _interpreter;
  List<String>? get labels => _labels;
}

이 클래스에서 인터프리터는 이미 학습된 객체 인식 모델을 로드하고, 이미지를 입력으로 사용하여 객체 인식을 수행한 뒤 인식된 객체에 대한 정보를 반환하는 역할을 합니다.

 

카메라로부터 얻은 이미지를 처리(변환) 하는 ImageUtils 클래스

import 'package:camera/camera.dart';
import 'package:image/image.dart' as imageLib;

// ImageUtils 클래스
class ImageUtils {
  // CameraImage 객체를 YUV420 형식에서 imageLib.Image(RGB 형식)으로 변환
  static imageLib.Image? convertCameraImage(CameraImage cameraImage) {
    // 이미지 형식에 따라 변환 함수 호출
    if (cameraImage.format.group == ImageFormatGroup.yuv420) {
      return convertYUV420ToImage(cameraImage);
    } else if (cameraImage.format.group == ImageFormatGroup.bgra8888) {
      return convertBGRA8888ToImage(cameraImage);
    } else {
      return null;
    }
  }
  // CameraImage 객체를 BGRA8888 형식에서 imageLib.Image(RGB 형식)으로 변환
  static imageLib.Image convertBGRA8888ToImage(CameraImage cameraImage) {
    imageLib.Image img = imageLib.Image.fromBytes(
        cameraImage.planes[0].width ?? 0,
        cameraImage.planes[0].height ?? 0,
        cameraImage.planes[0].bytes,
        format: imageLib.Format.bgra);
    return img;
  }
  // CameraImage 객체를 YUV420 형식에서 imageLib.Image(RGB 형식)으로 변환
  static imageLib.Image convertYUV420ToImage(CameraImage cameraImage) {
    // 이미지의 너비와 높이를 가져옴
    final int width = cameraImage.width;
    final int height = cameraImage.height;
    final int uvRowStride = cameraImage.planes[1].bytesPerRow;
    final int uvPixelStride = cameraImage.planes[1].bytesPerPixel ?? 0;
    final image = imageLib.Image(width, height);
    // 모든 픽셀을 YUV 값을 RGB로 변
    for (int w = 0; w < width; w++) {
      for (int h = 0; h < height; h++) {
        final int uvIndex =
            uvPixelStride * (w / 2).floor() + uvRowStride * (h / 2).floor();
        final int index = h * width + w;
        final y = cameraImage.planes[0].bytes[index];
        final u = cameraImage.planes[1].bytes[uvIndex];
        final v = cameraImage.planes[2].bytes[uvIndex];

        image.data[index] = ImageUtils.yuv2rgb(y, u, v);
      }
    }
    return image;
  }
  // 단일 YUV 픽셀을 RGB로 변환
  static int yuv2rgb(int y, int u, int v) {
    // YUV 픽셀 값을 RGB 값으로 변환
    int r = (y + v * 1436 / 1024 - 179).round();
    int g = (y - u * 46549 / 131072 + 44 - v * 93604 / 131072 + 91).round();
    int b = (y + u * 1814 / 1024 - 227).round();
    // RGB 값을 경계 [0, 255] 내에 있도록 설정
    r = r.clamp(0, 255);
    g = g.clamp(0, 255);
    b = b.clamp(0, 255);
    // 최종적으로 변환된 RGB 값 반환
    return 0xff000000 |
        ((b << 16) & 0xff0000) |
        ((g << 8) & 0xff00) |
        (r & 0xff);
  }
}

ImageUtils 클래스는 카메라로부터 캡처한 이미지를 처리하는 데 사용돼요. CameraImage 객체를 RGB 형식으로 변환하는 해주는데, 이 작업은 이미지 처리 및 객체 인식과 같은 다양한 작업에 필요한 전처리 단계입니다.

다중 이미지 추론의 처리 속도와 성능 향상을 위한 IsolateUtils 클래스

import 'dart:async';
import 'dart:isolate';
import 'package:camera/camera.dart';
import 'package:image/image.dart' as imageLib;
import 'package:untitled/services/classifier.dart';
import 'package:tflite_flutter/tflite_flutter.dart';
import 'image_utils.dart';

class IsolateUtils {
  Isolate? _isolate;
  ReceivePort _receivePort = ReceivePort();
  SendPort? _sendPort;
  SendPort? get sendPort => _sendPort;
  // Isolate 시작
  Future<void> start() async {
    _isolate = await Isolate.spawn<SendPort>(entryPoint, _receivePort.sendPort);
    _sendPort = await _receivePort.first;
  }
  // Isolate의 entryPoint 함수
  static void entryPoint(SendPort sendPort) async {
    final port = ReceivePort();
    sendPort.send(port.sendPort);
    // 전달받은 데이터를 사용하여 분류 및 추론 수행
    await for (final IsolateData isolateData in port) {
      Classifier classifier = Classifier(
          interpreter: Interpreter.fromAddress(isolateData.interpreterAddress),
          labels: isolateData.labels);
      imageLib.Image? image =
          ImageUtils.convertCameraImage(isolateData.cameraImage);
      image = imageLib.copyRotate(image!, 90);
      Map<String, dynamic>? results = classifier.predict(image);
      isolateData.responsePort?.send(results);
    }
  }
}
// Isolate 간에 전달할 데이터 class
class IsolateData {
  CameraImage cameraImage;
  int interpreterAddress;
  List<String> labels;
  SendPort? responsePort;

  IsolateData(
    this.cameraImage,
    this.interpreterAddress,
    this.labels,
  );
}

Isolate를 사용하면 여러 개의 독립적인 실행 스레드에서 동시에 이미지 추론을 처리할 수 있어요. 이를 통해 앱의 전체적인 처리 속도와 성능을 향상시킬 수 있답니다.

실시간으로 인식된 객체의 정보를 저장하고 처리하는 Recognition 클래스

import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:untitled/services/camera_settings.dart';

class Recognition {
  // 인식된 객체의 ID 저장
  final int? _id;
  // 인식된 객체의 레이블 저장
  final String? _label;
  // 인식된 객체의 확률(신뢰도) 저장
  final double? _score;
  // 인식된 객체의 위치 저장
  final Rect? _location;
  // Recognition 클래스 생성자
  Recognition(
      this._id,
      this._label,
      this._score, [
        this._location,
      ]);
  // 각 변수에 대한 getter
  int? get id => _id;
  String? get label => _label;
  double? get score => _score;
  Rect? get location => _location;
  // 객체의 위치를 화면에 맞게 변환하여 반환
  Rect get renderLocation {
    // 카메라 설정에서 비율 값
    double? ratioX = CameraSettings.ratio;
    double? ratioY = ratioX;
    // 위치 정보를 변환하여 좌표로 계산
    double transLeft = max(
      0.1,
      (location?.left ?? 0) * (ratioX ?? 0),
    );
    double transTop = max(
      0.1,
      (location?.top ?? 0) * (ratioY ?? 0),
    );
    double transWidth = min(
      (location?.width ?? 0) * (ratioX ?? 0),
      CameraSettings.actualPreviewSize.width,
    );
    double transHeight = min(
      (location?.height ?? 0) * (ratioY ?? 0),
      CameraSettings.actualPreviewSize.height,
    );
    // 변환된 위치 정보를 생성하여 반환
    Rect transformedRect = Rect.fromLTWH(
      transLeft,
      transTop,
      transWidth,
      transHeight,
    );
    return transformedRect;
  }
}

이 클래스는 객체 정보를 저장하고 인식된 객체의 위치 정보를 화면에 맞게 변환 돼요. 이 위치 정보를 기반으로 아래 BoxWidget에서 객체의 크기에 맞는 경계 박스를 만들 수 있습니다.

인식된 객체를 화면에 그리기 위한 BoxWidget

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

class BoxWidget extends StatelessWidget {
  final Recognition result;
  // BoxWidget 생성자
  const BoxWidget({Key? key, required this.result}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    // 결과에 따라 색상을 설정
    Color color = Colors.primaries[((result.label?.length ?? 0) +
        result.label!.codeUnitAt(0) +
        (result.id ?? 0)) %
        Colors.primaries.length];
    // Positioned 위젯을 사용하여 인식된 객체의 위치와 크기를 설정
    return Positioned(
      left: result.renderLocation.left,
      top: result.renderLocation.top,
      width: result.renderLocation.width,
      height: result.renderLocation.height,
      child: Container(
        width: result.renderLocation.width,
        height: result.renderLocation.height,
        // 경계 상자의 테두리
        decoration: BoxDecoration(
          border: Border.all(color: color, width: 3),
        ),
        child: Align(
          alignment: Alignment.topLeft,
          child: FittedBox(
            // 인식된 객체의 레이블과 확률 표시
            child: Container(
              color: color,
              child: Row(
                mainAxisSize: MainAxisSize.min,
                children: <Widget>[
                  Text('${result.label}  ${result.score!.toStringAsFixed(2)}'),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

BoxWidget 클래스는 인식된 객체의 위치와 크기에 따라 화면에 경계 박스를 그려줘요. 경계 상자 내에는 인식된 객체의 레이블과 확률이 표시되고 그리기에 사용되는 색상은 객체의 레이블에 따라 결정됩니다.

실시간 카메라 뷰를 생성하고 관리하는 CameraView

import 'dart:isolate';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:untitled/services/classifier.dart';
import 'package:untitled/services/recognition.dart';
import 'package:untitled/services/camera_settings.dart';
import 'package:untitled/utils/isolate_utils.dart';

//각 프레임을 추론에 전달하는 CameraView
class CameraView extends StatefulWidget {
  // 결과를 반환하기 위한 콜백 함수
  final Function(List<Recognition> recognitions) resultsCallback;
  // 추론 시간을 업데이트하기 위한 콜백 함수
  final Function(int elapsedTime) updateElapsedTimeCallback;
  // CameraView 생성자
  const CameraView(this.resultsCallback, this.updateElapsedTimeCallback);
  @override
  _CameraViewState createState() => _CameraViewState();
}
class _CameraViewState extends State<CameraView> with WidgetsBindingObserver {
  // 사용 가능한 카메라 목록
  List<CameraDescription>? cameras;
  // 카메라 컨트롤러
  CameraController? cameraController;
  // 추론 중일 때 true
  bool? predicting;
  // Classifier 인스턴스
  Classifier? classifier;
  // IsolateUtils 인스턴스
  IsolateUtils? isolateUtils;
  @override
  void initState() {
    super.initState();
    initStateAsync();
  }
  void initStateAsync() async {
    WidgetsBinding.instance.addObserver(this);
    // 새로운 Isolate를 생성
    isolateUtils = IsolateUtils();
    await isolateUtils?.start();
    // 카메라 초기화
    initializeCamera();
    // 모델 및 레이블을 로드하기 위해 Classifier 인스턴스 생성
    classifier = Classifier();
    // 초기에 predicting은 false로 설정
    predicting = false;
  }
  // 카메라를 초기화하고 cameraController를 설정
  void initializeCamera() async {
    cameras = await availableCameras();
    // cameras[0]은 후면 카메라
    cameraController =
        CameraController(cameras![0], ResolutionPreset.low, enableAudio: false);
    cameraController?.initialize().then((_) async {
      // onLatestImageAvailable 함수를 전달하여 각 프레임에 대한 인식을 수행
      await cameraController?.startImageStream(onLatestImageAvailable);
      // 현재 카메라의 미리보기의 크기
      Size? previewSize = cameraController?.value.previewSize;
      CameraSettings.inputImageSize = previewSize;
      // 해당 스마트폰의 화면의 크기
      Size screenSize = MediaQuery.of(context).size;
      CameraSettings.screenSize = screenSize;
      //프리뷰 프레임의 너비와 화면 너비 간의 비율
      CameraSettings.ratio = screenSize.width / (previewSize?.height ?? 0);
    });
  }
  @override
  Widget build(BuildContext context) {
    // 카메라가 초기화되지 않은 경우 빈 컨테이너 반환
    if (cameraController == null || !cameraController!.value.isInitialized) {
      return Container();
    }
    // 카메라 프리뷰
    return AspectRatio(
      //카메라 프리뷰 화면의 가로 세로 비율
      aspectRatio: 1 / cameraController!.value.aspectRatio,
      child: CameraPreview(cameraController!),
    );
  }
  // 프레임마다 호출
  onLatestImageAvailable(CameraImage cameraImage) async {
    if (classifier?.interpreter != null && classifier?.labels != null) {
      // 이전 추론이 완료되지 않은 경우 반환
      if (predicting ?? false) {
        return;
      }
      setState(() {
        // 이전 추론 완료
        predicting = true;
      });
      //추론 시작 시간
      var uiThreadTimeStart = DateTime.now().millisecondsSinceEpoch;
      // 추론 Isolate에 전달할 데이터
      var isolateData = IsolateData(cameraImage,
          classifier?.interpreter?.address ?? 0, classifier?.labels ?? []);
      // 추론 결과 반환
      Map<String, dynamic> inferenceResults = await inference(isolateData);
      // 추론 시간 계산
      var uiThreadInferenceElapsedTime =
          DateTime.now().millisecondsSinceEpoch - uiThreadTimeStart;
      // 결과를 HomeView로 전달
      widget.resultsCallback(inferenceResults["recognitions"]);
      // 추론 시간 HomeView로 전달
      widget.updateElapsedTimeCallback(uiThreadInferenceElapsedTime);
      // 새로운 프레임을 허용하기 위해 predicting을 false로 설정
      setState(() {
        predicting = false;
      });
    }
  }
  // 다른 Isolate에서 추론을 실행
  Future<Map<String, dynamic>> inference(IsolateData isolateData) async {
    ReceivePort responsePort = ReceivePort();
    isolateUtils?.sendPort
        ?.send(isolateData..responsePort = responsePort.sendPort);
    var results = await responsePort.first;
    return results;
  }
  @override
  // 앱이 일시 중지되거나 재개될 때 호출
  void didChangeAppLifecycleState(AppLifecycleState state) async {
    switch (state) {
      // 앱이 일시 중지되면 카메라 컨트롤러의 이미지 스트림 중지
      case AppLifecycleState.paused:
        cameraController?.stopImageStream();
        break;
      // 앱이 재개되면 카메라 컨트롤러의 이미지 스트림을 다시 시작
      case AppLifecycleState.resumed:
        if (!cameraController!.value.isStreamingImages) {
          await cameraController?.startImageStream(onLatestImageAvailable);
        }
        break;
      default:
    }
  }
  @override
  // 앱이 화면에서 사라질 때 호출
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    cameraController?.dispose();
    super.dispose();
  }
}

CameraView 클래스는 카메라를 초기화하고, 카메라 프리뷰를 화면에 표시해 줍니다.

 

마지막으로 카메라 프리뷰 화면, 인식된 객체 정보, 추론 시간 및 이미지 크기를 모두 표시하는 앱의 메인 스크린 화면입니다. 별다른 기능은 없고 위 클래스들에서 생성한 결과만 표시해 주기 때문에 코드 설명 없이 파일로 올릴게요!

 

실시간 객체 인식 앱의 메인 화면 HomeScreen

home_screen.dart
0.00MB

 

main.dart는 HomeScreen을 호출만 해주면 돼요. 각 화면에서 초기화 작업을 하기 때문에 따로 작업할 게 없습니다!

import 'package:flutter/material.dart';
import 'package:untitled/views/home_screen.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: HomeView(),
    );
  }
}

 

 

드디어 앱 개발이 모두 끝났어요! 오늘은 이렇게 플러터로 텐서플로우 라이트와 모바일넷을 활용하여 실시간 객체 인식 앱을 만들어 보았습니다. 오늘 만든 앱은 기본적인 수준이지만 다양한 방법으로 확장하여 더욱 효과적인 애플리케이션을 개발할 수 있답니다.  - 끝 -