반응형
사용자가 카메라를 실행해서 코틀린에서 flutter 로 안경 및 선글라스를 착용했는지 여부는 YOLO를 이용해 실시간으로 감지 하는 기능을 만들어 봤습니다.
순서
- flutter 이벤트 호출
- ImageProxy → Bitmap 변환
- YOLO 모델로 Bitmap 분석
- 결과 중 "glasses" 클래스가 있는지 확인
- Flutter로 결과 전송 (EventSink)
- receiveBroadcastStream로 결과 값 실시간 수신
- 자원 정리 (imageProxy.close())
작성이의 경우 flutter 에서 초기에 이벤트를 호출 합니다.
// 안드로이드 안경 감지 yolo
static const EventChannel _androidGlassesEventChannel = EventChannel("native_glasses_stream");
// 안드로이드 안경 감지 결과 수신
Stream<bool> get glassesDetectionStream {
return _androidGlassesEventChannel.receiveBroadcastStream().map((event) {
return event == true;
});
}
if (Platform.isAndroid) {
nativeController.glassesDetectionStream.distinct().listen(
(hasGlasses) {
print("안경 감지됨: $hasGlasses");
if (hasGlasses && !_isGlasses) {
_isGlasses = true;
speakVoice("안경을 벗어주세요"); // 음성안내
}
if (!hasGlasses) {
_isGlasses = false;
}
},
onError: (e) {
print("안경 감지 스트림 에러: $e");
},
onDone: () {
print("안경 감지 스트림 종료됨");
},
);
}
MainActivity.kt 에서는 이벤트 체널을 통해
class MainActivity : FlutterActivity() {
private val GLASSES_CHANNEL = "native_glasses_stream"
private lateinit var cameraHandler: CameraHandler
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
cameraHandler = CameraHandler(this, this)
flutterEngine.platformViewsController.registry
.registerViewFactory("native-camera-preview", CameraPreviewFactory(cameraHandler))
EventChannel(flutterEngine.dartExecutor.binaryMessenger, GLASSES_CHANNEL).setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
Log.d("GlassesDebug", "onListen: EventChannel 연결됨")
cameraHandler.setGlassesEventSink(events)
}
override fun onCancel(arguments: Any?) {
Log.d("GlassesDebug", "EventChannel 연결 해제됨")
cameraHandler.clearGlassesEventSink()
}
})
}
}
CameraHandler.kt 에서 addOnCompleteListener를 이용해서 Bitmap 변환 후 YOLO 로 안경감지를 실행합니다.
더보기
addOnCompleteListener는 비동기 작업(Task)이 완료되었을 때 실행할 콜백을 등록하는 함수입니다. 주로 Firebase, CameraX, WorkManager, 또는 사용자 정의 비동기 로직에서 Task 객체에 붙여서 사용됩니다.
CameraX 이미지 스트림을 받아 얼굴 탐지를 수행하고, 탐지된 프레임에서 YOLO 모델로 안경(glasses) 착용 여부를 판단한 후 Flutter로 결과를 EventChannel을 통해 전달
private var faceDetector: FaceDetector = FaceDetection.getClient(
FaceDetectorOptions.Builder()
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE)
.setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_ALL)
.build()
)
private val glassesDetector = GlassesDetector(context)
fun setGlassesEventSink(sink: EventChannel.EventSink?) {
glassesEventSink = sink
}
fun clearGlassesEventSink() {
glassesEventSink = null
}
private fun processImage(imageProxy: ImageProxy) {
val mediaImage = imageProxy.image ?: run {
imageProxy.close()
return
}
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
val image = InputImage.fromMediaImage(mediaImage, rotationDegrees)
faceDetector.process(image)
.addOnFailureListener {
Log.e("CameraHandler", "Face detection failed", it)
}
.addOnCompleteListener {
// Bitmap 변환 후 YOLO 안경 감지
val bitmap = imageProxy.toBitmap() // extension 필요
val boxes = glassesDetector.detect(bitmap)
val containsGlasses = boxes.any { it.clsName.contains("glasses", true) }
glassesEventSink?.success(containsGlasses)
imageProxy.close()
}
}
GlassesDetector.kt 에서는 아래와 같지 설정해주었습니다.
(YOLO TFLite 모델을 통해 실시간 이미지에서 안경 착용 여부를 감지)
class GlassesDetector(private val context: Context) {
// TFLite 모델 인터프리터
private var interpreter: Interpreter
private var labels = mutableListOf<String>()
// 모델 입력 이미지의 너비, 높이, 출력 채널 수
private var tensorWidth = 0
private var tensorHeight = 0
private var numChannel = 0
private var numElements = 0
// 이미지 전처리: 정규화 후 float32로 변환
private val imageProcessor = ImageProcessor.Builder()
.add(NormalizeOp(0f, 255f)) // [0,255] → [0,1] 정규화
.add(CastOp(DataType.FLOAT32)) // float32로 캐스팅
.build()
init {
// GPU 지원 여부 확인 후 delegate 설정
val compatList = CompatibilityList()
val options = Interpreter.Options().apply {
if (compatList.isDelegateSupportedOnThisDevice) {
addDelegate(GpuDelegate(compatList.bestOptionsForThisDevice))
} else {
setNumThreads(4) // CPU 4스레드 사용
}
}
// 모델 로드
val model = FileUtil.loadMappedFile(context, Constants.MODEL_PATH)
interpreter = Interpreter(model, options)
// 입력 및 출력 텐서 형태 분석
val inputShape = interpreter.getInputTensor(0).shape() // 예: [1, 640, 640, 3]
val outputShape = interpreter.getOutputTensor(0).shape() // 예: [1, 6, 8400]
tensorWidth = inputShape[1]
tensorHeight = inputShape[2]
numChannel = outputShape[1] // 보통 6 (x, y, w, h, conf, class_conf...)
numElements = outputShape[2] // 보통 anchor 갯수 (ex. 8400)
// 클래스 라벨 파일(.txt) 로드
val inputStream = context.assets.open(Constants.LABELS_PATH)
val reader = BufferedReader(InputStreamReader(inputStream))
var line = reader.readLine()
while (!line.isNullOrEmpty()) {
labels.add(line) // 각 줄마다 라벨 저장
line = reader.readLine()
}
}
/**
* 주어진 Bitmap을 모델 입력 형태로 변환하고 감지 실행
*/
fun detect(bitmap: Bitmap): List<BoundingBox> {
// 모델 입력 크기로 리사이징
val resized = Bitmap.createScaledBitmap(bitmap, tensorWidth, tensorHeight, false)
// TFLite용 TensorImage로 변환
val tensorImage = TensorImage(DataType.FLOAT32)
tensorImage.load(resized)
// 전처리 수행 (정규화 + 타입 캐스팅)
val imageBuffer = imageProcessor.process(tensorImage).buffer
// 모델 출력 버퍼 준비
val output = TensorBuffer.createFixedSize(
intArrayOf(1, numChannel, numElements),
DataType.FLOAT32
)
// 추론 실행
interpreter.run(imageBuffer, output.buffer)
// 출력 결과를 파싱하여 바운딩 박스 추출
return extractBoxes(output.floatArray)
}
/**
* 모델의 출력 배열을 파싱하여 BoundingBox 리스트 반환
*/
private fun extractBoxes(array: FloatArray): List<BoundingBox> {
val boxes = mutableListOf<BoundingBox>()
// anchor별로 탐색
for (c in 0 until numElements) {
var maxConf = 0.7f
var maxIdx = -1
var j = 4 // 클래스 confidence는 5번째부터 시작
var idx = c + numElements * j
// 가장 확률 높은 클래스 탐색
while (j < numChannel) {
if (array[idx] > maxConf) {
maxConf = array[idx]
maxIdx = j - 4 // 클래스 인덱스 (0부터 시작)
}
j++
idx += numElements
}
// 신뢰도 기준 이상일 때만 박스 생성
if (maxIdx != -1) {
val label = labels[maxIdx]
// 중심 좌표 및 크기
val cx = array[c]
val cy = array[c + numElements]
val w = array[c + numElements * 2]
val h = array[c + numElements * 3]
// 좌상단/우하단 좌표 계산
val x1 = cx - w / 2f
val y1 = cy - h / 2f
val x2 = cx + w / 2f
val y2 = cy + h / 2f
// 화면 내에 존재하는 박스만 추가
if (x1 in 0f..1f && y1 in 0f..1f && x2 in 0f..1f && y2 in 0f..1f) {
boxes.add(
BoundingBox(
x1, y1, x2, y2, cx, cy, w, h,
maxConf, maxIdx, label
)
)
}
}
}
return boxes
}
}
요약
GlassesDetector | YOLO TFLite 모델 로딩 및 감지 전용 클래스 |
detect() | Bitmap 이미지를 받아 추론하고, 박스 리스트 반환 |
extractBoxes() | YOLO 형식의 float 배열 → BoundingBox 객체로 파싱 |
GpuDelegate | 가능 시 GPU로 추론 성능 최적화 |
labels.txt | 클래스 이름 정보가 저장된 텍스트 파일 |
함수를 모드 작성하였다면 학습된 모델을 디릭토리에 넣어줍니다.
작성이의 경우
이렇게 경로를 모델을 추가하고 안경과 선글라스만 잡기 위해
labels.txt 에 아래와 같이 작업해주었습니다.
glasses
sunglasses
끝.
반응형
'kotlin' 카테고리의 다른 글
window.addFlags 를 활용한 앱이 계속 켜져있게 하기 (1) | 2025.07.01 |
---|---|
Foreground 를 통한 안드로이드 시스템이 앱을 백그라운드로 밀거나 강제 종료하는 걸 막는 방법 (3) | 2025.07.01 |
flutter - kotlin MediaProject를 이용한 실시간 내 디바이스 화면 web에 공유 (2부) (1) | 2025.06.09 |
flutter - kotlin MediaProject를 이용한 실시간 내 디바이스 화면 web에 공유 (1부) (0) | 2025.06.09 |
kotlin 문법 정리 (2) (0) | 2023.07.08 |