-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMainActivity.kt
More file actions
554 lines (496 loc) · 20.9 KB
/
MainActivity.kt
File metadata and controls
554 lines (496 loc) · 20.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
package com.example.dlod
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.*
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.tensorflow.lite.support.image.TensorImage
import org.tensorflow.lite.task.vision.detector.Detection
import org.tensorflow.lite.task.vision.detector.ObjectDetector
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import android.graphics.YuvImage
import android.graphics.ImageFormat
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.layout.onGloballyPositioned
class MainActivity : ComponentActivity() {
companion object {
const val TAG = "TFLite - ODT"
private const val MAX_FONT_SIZE = 96F
}
private lateinit var cameraExecutor: ExecutorService
private var isRealTimeMode by mutableStateOf(false)
private var selectedBitmap by mutableStateOf<Bitmap?>(null)
private var detectionResults by mutableStateOf<List<DetectionResult>>(emptyList())
// Camera permission request
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
isRealTimeMode = true
} else {
Log.e(TAG, "Camera permission denied")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
cameraExecutor = Executors.newSingleThreadExecutor()
setContent {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
if (isRealTimeMode) {
CameraScreen(
onBackClick = {
isRealTimeMode = false
detectionResults = emptyList() // Clear detection results
},
detectionResults = detectionResults,
onDetectionResults = { results -> detectionResults = results }
)
} else {
MainScreen(
selectedBitmap = selectedBitmap,
onCaptureImageClick = {
checkCameraPermissionAndStart()
},
onSampleImageClick = { drawableRes ->
val bitmap = getSampleImage(drawableRes)
setViewAndDetect(bitmap)
}
)
}
}
}
}
}
private fun checkCameraPermissionAndStart() {
when {
ContextCompat.checkSelfPermission(
this,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED -> {
isRealTimeMode = true
}
else -> {
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
}
override fun onDestroy() {
super.onDestroy()
cameraExecutor.shutdown()
}
private fun runObjectDetection(bitmap: Bitmap): List<DetectionResult> {
return try {
val image = TensorImage.fromBitmap(bitmap)
val options = ObjectDetector.ObjectDetectorOptions.builder()
.setMaxResults(5)
.setScoreThreshold(0.5f)
.build()
val detector = ObjectDetector.createFromFileAndOptions(
this,
"1.tflite",
options
)
val results = detector.detect(image)
debugPrint(results)
results.map {
val category = it.categories.first()
val text = "${category.label}, ${category.score.times(100).toInt()}%"
DetectionResult(it.boundingBox, text)
}
} catch (e: Exception) {
Log.e(TAG, "Object detection failed", e)
emptyList()
}
}
private fun debugPrint(results: List<Detection>) {
for ((i, obj) in results.withIndex()) {
val box = obj.boundingBox
Log.d(TAG, "Detected object: $i")
Log.d(TAG, " boundingBox: (${box.left}, ${box.top}) - (${box.right},${box.bottom})")
for ((j, category) in obj.categories.withIndex()) {
Log.d(TAG, " Label $j: ${category.label}")
val confidence: Int = category.score.times(100).toInt()
Log.d(TAG, " Confidence: ${confidence}%")
}
}
}
private fun setViewAndDetect(bitmap: Bitmap) {
selectedBitmap = bitmap
lifecycleScope.launch(Dispatchers.Default) {
val results = runObjectDetection(bitmap)
val imgWithResult = drawDetectionResult(bitmap, results)
runOnUiThread {
selectedBitmap = imgWithResult
}
}
}
private fun getSampleImage(drawable: Int): Bitmap {
return BitmapFactory.decodeResource(resources, drawable, BitmapFactory.Options().apply {
inMutable = true
})
}
private fun drawDetectionResult(
bitmap: Bitmap,
detectionResults: List<DetectionResult>
): Bitmap {
val outputBitmap = bitmap.copy(Bitmap.Config.ARGB_8888, true)
val canvas = Canvas(outputBitmap)
val pen = Paint()
pen.textAlign = Paint.Align.LEFT
detectionResults.forEach {
pen.color = Color.RED
pen.strokeWidth = 8F
pen.style = Paint.Style.STROKE
val box = it.boundingBox
canvas.drawRect(box, pen)
val tagSize = Rect(0, 0, 0, 0)
pen.style = Paint.Style.FILL_AND_STROKE
pen.color = Color.YELLOW
pen.strokeWidth = 2F
pen.textSize = MAX_FONT_SIZE
pen.getTextBounds(it.text, 0, it.text.length, tagSize)
val fontSize: Float = pen.textSize * box.width() / tagSize.width()
if (fontSize < pen.textSize) pen.textSize = fontSize
var margin = (box.width() - tagSize.width()) / 2.0F
if (margin < 0F) margin = 0F
canvas.drawText(
it.text, box.left + margin,
box.top + tagSize.height().times(1F), pen
)
}
return outputBitmap
}
@Composable
fun CameraScreen(
onBackClick: () -> Unit,
detectionResults: List<DetectionResult>,
onDetectionResults: (List<DetectionResult>) -> Unit
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val density = LocalDensity.current.density
val previewView = remember { PreviewView(context) }
var previewSize by remember { mutableStateOf(androidx.compose.ui.geometry.Size.Zero) }
DisposableEffect(Unit) {
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
val imageAnalyzer = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also {
it.setAnalyzer(cameraExecutor, ObjectDetectionAnalyzer(previewSize) { results ->
onDetectionResults(results)
})
}
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageAnalyzer
)
} catch (exc: Exception) {
Log.e(TAG, "Use case binding failed", exc)
}
}, ContextCompat.getMainExecutor(context))
onDispose {
cameraProviderFuture.get().unbindAll()
}
}
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(
factory = { previewView },
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { coordinates ->
previewSize = androidx.compose.ui.geometry.Size(
coordinates.size.width.toFloat(),
coordinates.size.height.toFloat()
)
}
)
// Single Canvas for all detection results
Canvas(modifier = Modifier.fillMaxSize()) {
detectionResults.forEach { result ->
val box = result.boundingBox
// Draw bounding box
drawRect(
color = androidx.compose.ui.graphics.Color.Red,
topLeft = Offset(box.left, box.top),
size = Size(box.width(), box.height()),
style = Stroke(width = 8f)
)
}
}
// Text overlays
detectionResults.forEach { result ->
val box = result.boundingBox
if (box.top > 30f) {
Text(
text = result.text,
color = androidx.compose.ui.graphics.Color.Yellow,
fontSize = 12.sp,
modifier = Modifier
.offset(
x = (box.left / density).dp,
y = ((box.top - 30f) / density).dp
)
.background(
androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.7f),
shape = RoundedCornerShape(4.dp)
)
.padding(4.dp)
)
}
}
Button(
onClick = {
onDetectionResults(emptyList())
onBackClick()
},
modifier = Modifier
.align(Alignment.TopStart)
.padding(16.dp)
) {
Text("Back")
}
}
}
inner class ObjectDetectionAnalyzer(
private val previewSize: androidx.compose.ui.geometry.Size,
private val onResults: (List<DetectionResult>) -> Unit
) : ImageAnalysis.Analyzer {
private var lastAnalyzedTimestamp = 0L
private val analysisIntervalMs = 500L
override fun analyze(imageProxy: ImageProxy) {
val currentTimestamp = System.currentTimeMillis()
if (currentTimestamp - lastAnalyzedTimestamp >= analysisIntervalMs) {
val bitmap = imageProxyToBitmap(imageProxy)
if (bitmap != null && previewSize.width > 0 && previewSize.height > 0) {
val results = runObjectDetection(bitmap)
val transformedResults = transformCoordinates(results, bitmap)
onResults(transformedResults)
lastAnalyzedTimestamp = currentTimestamp
}
}
imageProxy.close()
}
private fun transformCoordinates(
results: List<DetectionResult>,
rotatedBitmap: Bitmap // Bitmap is already rotated to match display orientation
): List<DetectionResult> {
if (previewSize.width == 0f || previewSize.height == 0f || rotatedBitmap.width == 0 || rotatedBitmap.height == 0) {
Log.w(TAG, "Cannot transform coordinates: Invalid dimensions. Preview: $previewSize, Bitmap: ${rotatedBitmap.width}x${rotatedBitmap.height}")
return results // Not enough info to transform
}
val modelInputWidth = rotatedBitmap.width.toFloat()
val modelInputHeight = rotatedBitmap.height.toFloat()
val viewWidth = previewSize.width
val viewHeight = previewSize.height
// Calculate scale and offset for PreviewView.ScaleType.FILL_CENTER
// The image is scaled uniformly to fill the view, and centered.
// Excess parts of the image are cropped.
val scale: Float
val offsetX: Float
val offsetY: Float
val modelAspectRatio = modelInputWidth / modelInputHeight
val viewAspectRatio = viewWidth / viewHeight
if (modelAspectRatio > viewAspectRatio) {
// Model input is wider than the view (relative to aspect ratio).
// Scale to fit view height, width will be larger and cropped.
scale = viewHeight / modelInputHeight
offsetX = (viewWidth - modelInputWidth * scale) / 2f
offsetY = 0f
} else {
// Model input is taller than or same aspect as the view.
// Scale to fit view width, height will be larger and cropped.
scale = viewWidth / modelInputWidth
offsetX = 0f
offsetY = (viewHeight - modelInputHeight * scale) / 2f
}
// Log.d(TAG, "Transform: model(${modelInputWidth}x${modelInputHeight}), view(${viewWidth}x${viewHeight}), scale=$scale, offsetX=$offsetX, offsetY=$offsetY")
return results.map { result ->
val originalBox = result.boundingBox
val transformedLeft = originalBox.left * scale + offsetX
val transformedTop = originalBox.top * scale + offsetY
val transformedRight = originalBox.right * scale + offsetX
val transformedBottom = originalBox.bottom * scale + offsetY
val transformedBox = RectF(
transformedLeft,
transformedTop,
transformedRight,
transformedBottom
)
// Log.d(TAG, "OriginalBox: $originalBox -> TransformedBox: $transformedBox")
DetectionResult(transformedBox, result.text)
}
}
private fun imageProxyToBitmap(imageProxy: ImageProxy): Bitmap? {
return try {
val yBuffer = imageProxy.planes[0].buffer // Y
val vuBuffer = imageProxy.planes[2].buffer // VU
val ySize = yBuffer.remaining()
val vuSize = vuBuffer.remaining()
val nv21 = ByteArray(ySize + vuSize)
yBuffer.get(nv21, 0, ySize)
vuBuffer.get(nv21, ySize, vuSize)
val yuvImage = YuvImage(nv21, ImageFormat.NV21, imageProxy.width, imageProxy.height, null)
val out = java.io.ByteArrayOutputStream()
// Consider increasing JPEG quality if detection accuracy is impacted, e.g., 90
yuvImage.compressToJpeg(Rect(0, 0, imageProxy.width, imageProxy.height), 80, out)
val imageBytes = out.toByteArray()
var bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
// Apply rotation to the bitmap to match the display orientation
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
if (rotationDegrees != 0) {
val matrix = Matrix()
matrix.postRotate(rotationDegrees.toFloat())
val rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
if (rotatedBitmap != bitmap) { // Avoid recycling if createBitmap returned the same instance
bitmap.recycle()
}
bitmap = rotatedBitmap
}
bitmap
} catch (e: Exception) {
Log.e(TAG, "Error converting imageProxy to bitmap: ${e.message}", e)
null
}
}
}
}
@Composable
fun MainScreen(
selectedBitmap: Bitmap?,
onCaptureImageClick: () -> Unit,
onSampleImageClick: (Int) -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
if (selectedBitmap != null) {
Image(
bitmap = selectedBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = androidx.compose.ui.layout.ContentScale.Fit
)
} else {
Text(
text = stringResource(R.string.tv_placeholder),
fontSize = 40.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
Text(
text = stringResource(R.string.tv_description),
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp, bottom = 10.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.Center
) {
Image(
painter = painterResource(R.drawable.img_meal_one),
contentDescription = null,
modifier = Modifier
.size(80.dp)
.clickable { onSampleImageClick(R.drawable.img_meal_one) },
contentScale = androidx.compose.ui.layout.ContentScale.Crop
)
Spacer(modifier = Modifier.width(16.dp))
Image(
painter = painterResource(R.drawable.img_meal_two),
contentDescription = null,
modifier = Modifier
.size(80.dp)
.clickable { onSampleImageClick(R.drawable.img_meal_two) },
contentScale = androidx.compose.ui.layout.ContentScale.Crop
)
Spacer(modifier = Modifier.width(16.dp))
Image(
painter = painterResource(R.drawable.img_meal_three),
contentDescription = null,
modifier = Modifier
.size(80.dp)
.clickable { onSampleImageClick(R.drawable.img_meal_three) },
contentScale = androidx.compose.ui.layout.ContentScale.Crop
)
}
Button(
onClick = onCaptureImageClick,
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
Icon(
imageVector = Icons.Filled.PhotoCamera,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = "Start Real-time Detection",
style = MaterialTheme.typography.labelLarge
)
}
}
}
data class DetectionResult(val boundingBox: RectF, val text: String)