[Flutter] Flutter와 YOLO의 만남, 카메라 속 세상을 읽다

서연·2025년 10월 31일
post-thumbnail

📖 YOLO

  • Flutter 애플리케이션에서 YOLO (You Only Look Once) 객체 탐지 모델을 통합하여 사용하는 것을 의미한다.
  • 즉 Flutter 앱에서 실시간 이미지나 비디오 속 객체를 빠르고 정확하게 탐지할 수 있게 만드는 기술이다.

⚙️ YOLO 개요

  • 단일 컨볼루션 신경망 (CNN)을 사용해 이미지나 비디오 내 객체를 한 번에 감지하는 컴퓨터 비전 모델이다.
  • 실시간으로 여러 객체를 동시에 빠르고 정확하게 탐지 가능하다.
  • 객체 탐지를 회귀 문제로 접근하여 한 번의 네트워크 통과로 클래스와 위치를 동시에 예측한다.

🧩 YOLO 통합 방법

모델 변환

  • 학습된 YOLO 모델을 TensorFlow Lite 형식으로 변환한다.
  • TFLite는 경량화된 머신러닝 프레임워크로 모바일 환경에서 효율적으로 작동한다.

모델 파일 포함

  • 변환된 .tflite 모델 파일을 Flutter 프로젝트의 assets 폴더에 추가한다.

실시간 이미지 입력

  • camera 패키지를 이용해 디바이스 카메라 스트림을 받아온다.
  • 프레임 단위로 이미지를 캡처하여 모델에 입력한다.

추론 처리

  • tflite_flutter 패키지를 활용해 TFLite 모델을 로드 및 실행한다.
  • 결과로 바운딩 박스 + 라벨 + 확률 값을 획득한다.

UI 시각화

  • CustomPaint 또는 Canvas 위젯을 사용해 탐지된 객체의 박스와 이름을 화면에 오버레이 한다.

🧵 성능 최적화 포인트

비동기 처리

  • 카메라 프레임 전처리 및 모델 추론은 백그라운드 Isolate 또는 네이티브 코드에서 실행한다.
  • 이를 통해 UI 프레임 멈춤을 방지한다.

모델 최적화

  • 양자화하여 모델 크기를 낮추고 속도를 높인다.
  • GPU / NNAPI / Metal을 가속에 사용한다.
  • 입력 해상도를 적절히 조정하여 FPS를 확보한다.

경량 모델 사용

  • 모바일 환경에서는 YOLOv8-nano, YOLOv5s 등 경량화 모델을 사용해 정확도와 속도의 균형을 유지한다.

💡 활용 사례

분야 설명
보안 감시 시스템카메라 영상을 실시간 분석하여 침입자 감지
자율 주행 보조 앱도로 위 차량, 보행자 인식
소매 재고 관리상품 인식 및 자동 재고 체크
의료 영상 분석영상 내 이상 부위 탐지
AR 기반 애플리케이션현실 객체 인식 후 가상요소 오버레이

🧠 YOLO 객체 감지 바운딩 박스 코드

// Bbox는 Flutter의 화면 구성 요소
// StatelessWidget은 변하지 않는 정적인 위젯 만들 때 사용
class Bbox extends StatelessWidget {
  Bbox({
    required this.detectedObject, // AI가 찾아낸 물체의 정보 (위치, 크기 등)
    required this.imageWidth, // 원본 이미지의 가로 크기
    required this.imageHeight, // 원본 이미지의 세로 크기
    required this.label, // 물체의 이름
  });
  final DetectedObject detectedObject;
  final int imageWidth;
  final int imageHeight;
  final String label;
  
  Widget build(BuildContext context) {
    // 원본 이미지 좌표 기준으로 이미지가 배치되고 있는 Stack 내에서 위치를 계산해주기 위해서
    // 사용자 스마트폰 화면의 실제 가로 크기를 가져옴
    final deviceWidth = MediaQuery.sizeOf(context).width;

    // 원본 이미지 내에서 위치를 Stack 내 위치로 바꿔주기 위해서
    // 원본 이미지와 화면 크기가 다르기 때문에 비율 계산
    final resizeFactor = deviceWidth / imageWidth;

    // 객체를 감싸고 있는 박스의 중간 좌표
    // 박스 위치와 크기 재계산
    // YOLO가 찾은 좌표는 원본 이미지 기준
    // 스마트폰 화면에는 축소된 이미지가 표시되므로 박스 위치도 그에 맞게 축소해야 함
    final resizedX = detectedObject.x * resizeFactor;
    final resizedY = detectedObject.y * resizeFactor;
    // 박스의 크기
    final resizedW = detectedObject.width * resizeFactor;
    final resizedH = detectedObject.height * resizeFactor;

  // 랜덤 색상 생성
  // 각 박스마다 다른 색상을 만들기 위한 난수 생성기
    final random = Random();

  // 실제 박스 그리기
    return Positioned(
      left: resizedX - resizedW / 2,
      top: resizedY - resizedH / 2,
      // 박스 디자인
      child: Container(
        width: resizedW,
        height: resizedH,
        decoration: BoxDecoration(
            border: Border.all(
                color: Color(
                  0xFF000000 + random.nextInt(0xFFFFFF), // 랜덤 숫자
                ),
                width: 3)),
        // 라벨 표시
        child: Text(
          label,
          style: TextStyle(
            color: Colors.red,
            fontWeight: FontWeight.bold,
            fontSize: 11,
          ),
        ),
      ),
    );
  }
}

🧠 YOLO 이미지 탐지 코드

// TFLite 이용 => 이미지 추론하는 기능 작성
class YoloDetection {
  // 추가한 labels.txt 파일에 적혀있는 물체 이름들을 저장하는 리스트
  // null 일 수도 있으니 Nullable 형태
  List<String>? _labels;
  // 번호를 주면 해당하는 물체 이름을 돌려주는 함수
  String label(int index) => _labels![index];

  // interpreter 객체가 TFLITE에서 제공해주는 클래스
  // 모델을 로드하고 추론을 할 수 있게 해주는 클래스
  // AI 모델을 실행시키는 핵심 객체
  Interpreter? _interpreter;
  // 외부에서 클래스 사용할 때 클래스에 init 메서드 호출여부 확인할 수 있게 함수 만들어줌
  // 모델과 라벨이 제대로 준비됐는지 확인하는 함수
  // 둘 다 준비되면 true, 하나라도 없으면 false
  bool get isInit => _interpreter != null && _labels != null;

  Future<void> init() async {
    // 이 함수 이용하면 assets 에 있는 모델 로드에서 Interpreter 객체를 리턴해줌
  // AI 모델 파일 로드
    _interpreter = await Interpreter.fromAsset('assets/yolov8.tflite');
    // assets 폴더로 접근을 해서 문자열로 불러와줌
    // 물체 이름 목록 파일 로드
    final labelStrings = await rootBundle.loadString('assets/labels.txt');
    // 문자열로 불러온 파일 개행되어 있는 단위로 나눠서 배열에 담아줌
    // 줄바꿈 기준으로 쪼개서 리스트로 만들기
    _labels = labelStrings.split('\n');
  }

  // 추론하는 함수
  // 화면에서 image_picker 선택해서 함수 실행할 때 이미지 객체로 바꿔서 넘겨주게 구현
  // 그래야 사이즈 변환하기 편함
  // 이미지를 넣으면 거기서 발견된 물체 목록을 돌려줌
  List<DetectedObject> runInference(Image image) {
  // 초기화 확인
  // 모델이 준비 안됐으면 에러 발생 (먼저 init() 호출해야 함)
    if (!isInit) {
      throw Exception('The model must be initialized');
    }

    // 이미지 크기 조정
    // YOLO 모델은 640 X 640 크기의 이미지만 처리할 수 있음
    final resizedImage = copyResize(image, width: 640, height: 640);

    // 이미지 픽셀 정규화
    // 640 X 640 이미지의 모든 픽셀을 순회
    final imageNormalized = List.generate(
      640, // 세로 640 픽셀
      (y) => List.generate(
        640, // 가로 640 픽셀
        (x) {
          // 각 픽셀의 RGB 값을 가져옴
          final pixel = resizedImage.getPixel(x, y);
          return [pixel.rNormalized, pixel.gNormalized, pixel.bNormalized];
        },
      ),
    );

    // 출력 데이터 구조 준비
    // output은 딥러닝 모델에서 예측 결과 담는 데이터 구조를 의미함
    final output = [
      List<List<double>>.filled(
        84, // 각 물체당 정보 84개
        List<double>.filled(8400, 0), // 최대 8400개 물체 탐지 가능
      )
    ];

    // run 함수가 실행되면 output 의 값들이 채워짐
    // AI 모델 실행
    _interpreter!.run([imageNormalized], output);
  // 결과 해석
   return YoloHelper.parse(
      output[0], // AI 모델의 원시 출력
      image.width, // 원본 이미지 너비
      image.height, // 원본 이미지 높이
    );
  }
}

🧠 YOLO 적용

// StatefulWidget은 상태가 변할 수 있는 위젯
// 사진을 선택하고 인식 결과가 나오면 화면이 바뀌어야 하니까 사용
class HomePage extends StatefulWidget {
  
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final model = YoloDetection(); // YOLO AI 모델 객체
  final picker = ImagePicker(); // 이미지 선택 도구
  List<DetectedObject>? detectedObjects; // 인식된 물체들 리스트 (아직 없으면 null)
  Uint8List? imageBytes; // 선택한 이미지의 바이트 데이터 (아직 없으면 null)
  int? imageWidth; // 이미지 가로 크기
  int? imageHeight; // 이미지 세로 크기

  
  // 위젯이 처음 생성될 때 실행
  void initState() {
    super.initState();
    model.init(); // AI 모델 초기화 (모델 파일 로드 등)
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(), // 상단 바
      body: GestureDetector( // 터치 감지 위젯
        onTap: () async { // 화면을 터치하면 실행되는 함수
          //추론하고 결과 리턴받는 로직
          // 갤러리에서 이미지 선택
          final xFile = await picker.pickImage(source: ImageSource.gallery);
          // 사용자가 이미지를 선택했는지 확인
          if (xFile != null) {
            // 이미지를 바이트 데이터로 변환
            final bytes = await xFile.readAsBytes();
            // 바이트 데이터를 image 패키지의 이미지 객체로 변환
            final image = img.decodeImage(bytes);
            // AI 모델로 물체 인식 실행
            final results = model.runInference(image!);
            // 이미지 크기 저장
            imageHeight = image.height;
            imageWidth = image.width;
            // 결과를 화면에 반영 (setState를 호출하면 화면에 다시 그려짐)
            setState(() {
              detectedObjects = results; // 인식된 물체들
              imageBytes = bytes; // 이미지 데이터
            });
          }
        },

        child: ListView(
          children: [
           // 이미지가 없으면 파일 아이콘 표시
            if (imageBytes == null)
              Icon(
                Icons.file_open_outlined,
                size: 80,
              )
           // 이미지가 있으면 이미지와 박스들 표시
            else
              Stack( // 겹쳐서 표시할 때 사용
                children: [
                  // 선택한 이미지 표시
                  AspectRatio(
                      aspectRatio: imageWidth! / imageHeight!, // 이미지 비율 유지
                      child: Image.memory(
                        imageBytes!, // 바이트 데이터로 이미지 표시
                        fit: BoxFit.cover,
                      )),

                 // 인식된 물체들 위에 박스 그리기
                  if (detectedObjects != null)
                    ...detectedObjects!.map((e) { // 각 인식된 물체마다
                      return Bbox( // 박스 위젯 생성
                        detectedObject: e, // 물체 정보
                        imageWidth: imageWidth!,
                        imageHeight: imageHeight!,
                        label: model.label(e.labelIndex), // 물체 이름
                      );
                    })
                ],
              ),
          ],
        ),
      ),
    );
  }
}

0개의 댓글