初始化 observer 项目:纯代码,不含权重与训练数据
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.observer"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.example.observer"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
implementation(libs.androidx.camera.core)
|
||||
implementation(libs.androidx.camera.camera2)
|
||||
implementation(libs.androidx.camera.lifecycle)
|
||||
implementation(libs.androidx.camera.view)
|
||||
|
||||
implementation(libs.org.tensorflow.lite)
|
||||
implementation(libs.org.tensorflow.lite.gpu)
|
||||
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
# TensorFlow Lite
|
||||
-keep class org.tensorflow.** { *; }
|
||||
-dontwarn org.tensorflow.**
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera.any" android:required="true" />
|
||||
|
||||
<application
|
||||
android:name=".ObserverApp"
|
||||
android:allowBackup="false"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Observer">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,4 @@
|
||||
pheasant
|
||||
hare
|
||||
dove
|
||||
fish
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.observer
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import com.example.observer.ui.ObserverApp
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
ObserverApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.example.observer
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import com.example.observer.data.SettingsRepository
|
||||
import com.example.observer.detection.Detector
|
||||
import com.example.observer.detection.TFLiteDetector
|
||||
import com.example.observer.distance.DistanceEstimator
|
||||
import com.example.observer.reminder.Reminder
|
||||
|
||||
class ObserverApp : Application() {
|
||||
lateinit var container: AppContainer
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = AppContainer(this)
|
||||
}
|
||||
}
|
||||
|
||||
class AppContainer(context: Context) {
|
||||
val settingsRepository = SettingsRepository(context)
|
||||
val detector: Detector? = TFLiteDetector.create(context)
|
||||
val distanceEstimator = DistanceEstimator(context)
|
||||
val reminder = Reminder(context)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.example.observer.camera
|
||||
|
||||
import android.util.Log
|
||||
import androidx.camera.core.Camera
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.camera2.interop.Camera2CameraInfo
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
class CameraController(
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
private val analysisExecutor: Executor,
|
||||
) {
|
||||
var onCameraIdChanged: ((String) -> Unit)? = null
|
||||
|
||||
var onInitError: (() -> Unit)? = null
|
||||
|
||||
var currentCameraId: String? = null
|
||||
private set
|
||||
|
||||
private var cameraProvider: ProcessCameraProvider? = null
|
||||
private var isFrontFacing = false
|
||||
|
||||
fun start(previewView: PreviewView, analyzer: ImageAnalysis.Analyzer?) {
|
||||
val future = ProcessCameraProvider.getInstance(previewView.context)
|
||||
future.addListener({
|
||||
try {
|
||||
val provider = future.get()
|
||||
cameraProvider = provider
|
||||
bind(previewView, analyzer)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "相机初始化失败", e)
|
||||
onInitError?.invoke()
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(previewView.context))
|
||||
}
|
||||
|
||||
fun switchCamera(previewView: PreviewView, analyzer: ImageAnalysis.Analyzer?) {
|
||||
isFrontFacing = !isFrontFacing
|
||||
bind(previewView, analyzer)
|
||||
}
|
||||
|
||||
private fun bind(previewView: PreviewView, analyzer: ImageAnalysis.Analyzer?) {
|
||||
val provider = cameraProvider ?: return
|
||||
provider.unbindAll()
|
||||
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.surfaceProvider = previewView.surfaceProvider
|
||||
}
|
||||
val imageAnalysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888)
|
||||
.build()
|
||||
analyzer?.let { imageAnalysis.setAnalyzer(analysisExecutor, it) }
|
||||
|
||||
val camera: Camera = provider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
currentSelector(),
|
||||
preview,
|
||||
imageAnalysis,
|
||||
)
|
||||
currentCameraId = try {
|
||||
Camera2CameraInfo.from(camera.cameraInfo).cameraId
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "获取 cameraId 失败", e)
|
||||
null
|
||||
}
|
||||
currentCameraId?.let { onCameraIdChanged?.invoke(it) }
|
||||
}
|
||||
|
||||
private fun currentSelector(): CameraSelector =
|
||||
if (isFrontFacing) CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CameraController"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.example.observer.camera
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import com.example.observer.detection.DetectionResult
|
||||
import com.example.observer.detection.Detector
|
||||
import com.example.observer.detection.MotionRegion
|
||||
|
||||
class FrameAnalyzer(
|
||||
private val detector: Detector,
|
||||
private val motionDetector: MotionDetector,
|
||||
private val onResult: (results: List<DetectionResult>, rotation: Int, imageWidthPx: Int, imageHeightPx: Int, motionRegions: List<MotionRegion>) -> Unit,
|
||||
) : ImageAnalysis.Analyzer {
|
||||
|
||||
@Volatile var intervalMs: Long = 100L
|
||||
|
||||
private var lastDetectMs = 0L
|
||||
|
||||
override fun analyze(imageProxy: ImageProxy) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (now - lastDetectMs < intervalMs) {
|
||||
imageProxy.close()
|
||||
return
|
||||
}
|
||||
lastDetectMs = now
|
||||
|
||||
val bitmap = imageProxy.toBitmap()
|
||||
val rotation = imageProxy.imageInfo.rotationDegrees
|
||||
try {
|
||||
val motionRegions = motionDetector.detectMotion(bitmap)
|
||||
val results = detector.detect(bitmap)
|
||||
onResult(results, rotation, bitmap.width, bitmap.height, motionRegions)
|
||||
} finally {
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.example.observer.camera
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import com.example.observer.detection.MotionAggregator
|
||||
import com.example.observer.detection.MotionRegion
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* 轻量运动检测:相邻帧灰度差分 + 分块聚合。
|
||||
* 小尺寸工作(约 128x128 内),每帧开销亚毫秒级,在分析线程串行调用。
|
||||
* 相机大幅移动时(全屏帧差)自动忽略本帧,避免误报。
|
||||
*/
|
||||
class MotionDetector(
|
||||
private val maxWidth: Int = 128,
|
||||
private val maxHeight: Int = 128,
|
||||
) {
|
||||
|
||||
private val smallBitmap = Bitmap.createBitmap(maxWidth, maxHeight, Bitmap.Config.ARGB_8888)
|
||||
private val canvas = Canvas(smallBitmap)
|
||||
private val paint = Paint(Paint.FILTER_BITMAP_FLAG)
|
||||
|
||||
private var prevGray: IntArray? = null
|
||||
private var grayCache: IntArray = IntArray(0)
|
||||
|
||||
fun detectMotion(bitmap: Bitmap): List<MotionRegion> {
|
||||
val scale = min(maxWidth.toFloat() / bitmap.width, maxHeight.toFloat() / bitmap.height)
|
||||
val w = (bitmap.width * scale).toInt()
|
||||
val h = (bitmap.height * scale).toInt()
|
||||
if (w == 0 || h == 0) return emptyList()
|
||||
|
||||
canvas.drawBitmap(bitmap, null, Rect(0, 0, w, h), paint)
|
||||
if (grayCache.size != w * h) grayCache = IntArray(w * h)
|
||||
smallBitmap.getPixels(grayCache, 0, w, 0, 0, w, h)
|
||||
|
||||
val prev = prevGray
|
||||
prevGray = grayCache.copyOf()
|
||||
if (prev == null || prev.size != grayCache.size) return emptyList()
|
||||
|
||||
// 取绿色通道近似亮度
|
||||
val gray = IntArray(grayCache.size)
|
||||
for (i in grayCache.indices) gray[i] = (grayCache[i] shr 8) and 0xFF
|
||||
val prevGreen = IntArray(prev.size)
|
||||
for (i in prev.indices) prevGreen[i] = (prev[i] shr 8) and 0xFF
|
||||
|
||||
val diff = MotionAggregator.diffMask(gray, prevGreen)
|
||||
val motionTotal = diff.sum()
|
||||
// 全屏大差异 → 相机移动/大范围变化,忽略本帧
|
||||
if (motionTotal > w * h / 2) return emptyList()
|
||||
if (motionTotal < 12) return emptyList()
|
||||
return MotionAggregator.aggregate(diff, w, h)
|
||||
}
|
||||
|
||||
/** 相机切换后重置参考帧,避免旧帧误差 */
|
||||
fun reset() {
|
||||
prevGray = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.example.observer.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
enum class DetectMode(val intervalMs: Long, val label: String) {
|
||||
CONTINUOUS(100L, "连续"),
|
||||
STANDARD(300L, "标准"),
|
||||
POWER_SAVING(1000L, "省电"),
|
||||
}
|
||||
|
||||
data class AppSettings(
|
||||
val confThreshold: Float = 0.40f,
|
||||
val habitatThreshold: Float = 0.35f,
|
||||
val detectMode: DetectMode = DetectMode.CONTINUOUS,
|
||||
val vibrateEnabled: Boolean = true,
|
||||
val soundEnabled: Boolean = true,
|
||||
val showDistance: Boolean = true,
|
||||
val habitatAlertEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
private val Context.settingsDataStore by preferencesDataStore(name = "settings")
|
||||
|
||||
class SettingsRepository(private val context: Context) {
|
||||
|
||||
val settings: Flow<AppSettings> = context.settingsDataStore.data.map { prefs ->
|
||||
AppSettings(
|
||||
confThreshold = prefs[KEY_CONF] ?: 0.40f,
|
||||
habitatThreshold = prefs[KEY_HABITAT] ?: 0.35f,
|
||||
detectMode = DetectMode.entries.firstOrNull { it.name == prefs[KEY_MODE] }
|
||||
?: DetectMode.CONTINUOUS,
|
||||
vibrateEnabled = prefs[KEY_VIBRATE] ?: true,
|
||||
soundEnabled = prefs[KEY_SOUND] ?: true,
|
||||
showDistance = prefs[KEY_DISTANCE] ?: true,
|
||||
habitatAlertEnabled = prefs[KEY_HABITAT_ALERT] ?: true,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setConfThreshold(value: Float) =
|
||||
context.settingsDataStore.edit { it[KEY_CONF] = value }
|
||||
|
||||
suspend fun setHabitatThreshold(value: Float) =
|
||||
context.settingsDataStore.edit { it[KEY_HABITAT] = value }
|
||||
|
||||
suspend fun setDetectMode(mode: DetectMode) =
|
||||
context.settingsDataStore.edit { it[KEY_MODE] = mode.name }
|
||||
|
||||
suspend fun setVibrateEnabled(value: Boolean) =
|
||||
context.settingsDataStore.edit { it[KEY_VIBRATE] = value }
|
||||
|
||||
suspend fun setSoundEnabled(value: Boolean) =
|
||||
context.settingsDataStore.edit { it[KEY_SOUND] = value }
|
||||
|
||||
suspend fun setShowDistance(value: Boolean) =
|
||||
context.settingsDataStore.edit { it[KEY_DISTANCE] = value }
|
||||
|
||||
suspend fun setHabitatAlertEnabled(value: Boolean) =
|
||||
context.settingsDataStore.edit { it[KEY_HABITAT_ALERT] = value }
|
||||
|
||||
companion object {
|
||||
private val KEY_CONF = floatPreferencesKey("conf_threshold")
|
||||
private val KEY_HABITAT = floatPreferencesKey("habitat_threshold")
|
||||
private val KEY_MODE = stringPreferencesKey("detect_mode")
|
||||
private val KEY_VIBRATE = booleanPreferencesKey("vibrate_enabled")
|
||||
private val KEY_SOUND = booleanPreferencesKey("sound_enabled")
|
||||
private val KEY_DISTANCE = booleanPreferencesKey("show_distance")
|
||||
private val KEY_HABITAT_ALERT = booleanPreferencesKey("habitat_alert_enabled")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.example.observer.detection
|
||||
|
||||
/** 预览视图像素坐标矩形(纯 JVM 类型,便于单元测试) */
|
||||
data class ViewRect(
|
||||
val left: Float,
|
||||
val top: Float,
|
||||
val right: Float,
|
||||
val bottom: Float,
|
||||
) {
|
||||
val width: Float get() = right - left
|
||||
val height: Float get() = bottom - top
|
||||
val centerX: Float get() = (left + right) / 2f
|
||||
val centerY: Float get() = (top + bottom) / 2f
|
||||
}
|
||||
|
||||
object CoordinateMapper {
|
||||
|
||||
/** 模型归一化坐标 → 预览视图坐标(含传感器旋转与 FIT_CENTER 裁剪) */
|
||||
fun mapToView(
|
||||
normLeft: Float,
|
||||
normTop: Float,
|
||||
normRight: Float,
|
||||
normBottom: Float,
|
||||
rotation: Int, // ImageProxy.imageInfo.rotationDegrees
|
||||
imageW: Int,
|
||||
imageH: Int, // 分析图像尺寸(横屏原图)
|
||||
viewW: Int,
|
||||
viewH: Int, // 预览视图尺寸
|
||||
): ViewRect {
|
||||
// 1) 旋转校正:图像方向 → 竖屏视图方向(归一化坐标)
|
||||
val (x0, y0, x1, y1) = when (rotation) {
|
||||
90 -> Quad(1 - normBottom, normLeft, 1 - normTop, normRight)
|
||||
180 -> Quad(1 - normRight, 1 - normBottom, 1 - normLeft, 1 - normTop)
|
||||
270 -> Quad(normTop, 1 - normRight, normBottom, 1 - normLeft)
|
||||
else -> Quad(normLeft, normTop, normRight, normBottom)
|
||||
}
|
||||
// 2) 旋转后图像在竖屏方向上的尺寸
|
||||
val portrait = rotation == 90 || rotation == 270
|
||||
val portW = if (portrait) imageH else imageW
|
||||
val portH = if (portrait) imageW else imageH
|
||||
// 3) FIT_CENTER 缩放与居中偏移
|
||||
val scale = minOf(viewW.toFloat() / portW, viewH.toFloat() / portH)
|
||||
val offsetX = (viewW - portW * scale) / 2f
|
||||
val offsetY = (viewH - portH * scale) / 2f
|
||||
return ViewRect(
|
||||
left = x0 * portW * scale + offsetX,
|
||||
top = y0 * portH * scale + offsetY,
|
||||
right = x1 * portW * scale + offsetX,
|
||||
bottom = y1 * portH * scale + offsetY,
|
||||
)
|
||||
}
|
||||
|
||||
private data class Quad(val x0: Float, val y0: Float, val x1: Float, val y1: Float)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.example.observer.detection
|
||||
|
||||
data class DetectionResult(
|
||||
val label: String,
|
||||
val score: Float,
|
||||
val left: Float,
|
||||
val top: Float,
|
||||
val right: Float,
|
||||
val bottom: Float,
|
||||
val distanceM: Float? = null,
|
||||
) {
|
||||
val width: Float get() = right - left
|
||||
val height: Float get() = bottom - top
|
||||
val centerX: Float get() = (left + right) / 2f
|
||||
val centerY: Float get() = (top + bottom) / 2f
|
||||
val isHabitat: Boolean get() = label == HABITAT_LABEL
|
||||
|
||||
companion object {
|
||||
const val HABITAT_LABEL = "cover"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.example.observer.detection
|
||||
|
||||
import android.graphics.Bitmap
|
||||
|
||||
interface Detector {
|
||||
/** 输入 RGBA 位图,输出归一化坐标检测结果(0..1) */
|
||||
fun detect(bitmap: Bitmap): List<DetectionResult>
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.example.observer.detection
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* 帧差运动聚合(纯 JVM,可单测)。
|
||||
* 输入:每像素 0/1 差分掩码,按 8x8 分块统计激活块,连通块聚合为运动区域。
|
||||
*/
|
||||
object MotionAggregator {
|
||||
|
||||
private const val BLOCK_GRID = 8
|
||||
private const val BLOCK_ACTIVE_RATIO = 0.30f
|
||||
private const val MAX_REGIONS = 3
|
||||
|
||||
fun aggregate(
|
||||
diff: IntArray,
|
||||
width: Int,
|
||||
height: Int,
|
||||
): List<MotionRegion> {
|
||||
val bw = width / BLOCK_GRID
|
||||
val bh = height / BLOCK_GRID
|
||||
if (bw == 0 || bh == 0) return emptyList()
|
||||
|
||||
val active = BooleanArray(BLOCK_GRID * BLOCK_GRID)
|
||||
for (by in 0 until BLOCK_GRID) {
|
||||
for (bx in 0 until BLOCK_GRID) {
|
||||
val blockW = if (bx == BLOCK_GRID - 1) width - bx * bw else bw
|
||||
val blockH = if (by == BLOCK_GRID - 1) height - by * bh else bh
|
||||
var motion = 0
|
||||
for (y in by * bh until by * bh + blockH) {
|
||||
var idx = y * width + bx * bw
|
||||
for (x in 0 until blockW) {
|
||||
motion += diff[idx + x]
|
||||
}
|
||||
idx += width
|
||||
}
|
||||
active[by * BLOCK_GRID + bx] = motion > blockW * blockH * BLOCK_ACTIVE_RATIO
|
||||
}
|
||||
}
|
||||
|
||||
// 连通块聚合(4 邻域)
|
||||
val regions = ArrayList<MotionRegion>(MAX_REGIONS)
|
||||
val visited = BooleanArray(active.size)
|
||||
for (i in active.indices) {
|
||||
if (!active[i] || visited[i]) continue
|
||||
var minX = BLOCK_GRID
|
||||
var minY = BLOCK_GRID
|
||||
var maxX = -1
|
||||
var maxY = -1
|
||||
val stack = ArrayDeque<Int>()
|
||||
stack.add(i)
|
||||
visited[i] = true
|
||||
while (stack.isNotEmpty()) {
|
||||
val cur = stack.removeLast()
|
||||
val bx = cur % BLOCK_GRID
|
||||
val by = cur / BLOCK_GRID
|
||||
minX = minOf(minX, bx)
|
||||
maxX = maxOf(maxX, bx)
|
||||
minY = minOf(minY, by)
|
||||
maxY = maxOf(maxY, by)
|
||||
for (nb in neighbors(cur)) {
|
||||
if (active[nb] && !visited[nb]) {
|
||||
visited[nb] = true
|
||||
stack.add(nb)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxX - minX > 3 || maxY - minY > 3) continue // 全屏噪声过滤
|
||||
regions += MotionRegion(
|
||||
left = minX * bw / width.toFloat(),
|
||||
top = minY * bh / height.toFloat(),
|
||||
right = ((maxX + 1) * bw).coerceAtMost(width) / width.toFloat(),
|
||||
bottom = ((maxY + 1) * bh).coerceAtMost(height) / height.toFloat(),
|
||||
)
|
||||
if (regions.size >= MAX_REGIONS) break
|
||||
}
|
||||
return regions
|
||||
}
|
||||
|
||||
private fun neighbors(i: Int): List<Int> {
|
||||
val bx = i % BLOCK_GRID
|
||||
val by = i / BLOCK_GRID
|
||||
val list = ArrayList<Int>(4)
|
||||
if (bx > 0) list += i - 1
|
||||
if (bx < BLOCK_GRID - 1) list += i + 1
|
||||
if (by > 0) list += i - BLOCK_GRID
|
||||
if (by < BLOCK_GRID - 1) list += i + BLOCK_GRID
|
||||
return list
|
||||
}
|
||||
|
||||
/** 检测框中心是否落在运动区域内(用于置信度提升判定) */
|
||||
fun centerInRegion(box: DetectionResult, region: MotionRegion): Boolean =
|
||||
box.centerX in region.left..region.right && box.centerY in region.top..region.bottom
|
||||
|
||||
/** 帧差掩码生成(纯 JVM):|g - prev| > threshold → 1 */
|
||||
fun diffMask(gray: IntArray, prev: IntArray, threshold: Int = 25): IntArray {
|
||||
val diff = IntArray(gray.size)
|
||||
for (i in gray.indices) {
|
||||
diff[i] = if (abs(gray[i] - prev[i]) > threshold) 1 else 0
|
||||
}
|
||||
return diff
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.example.observer.detection
|
||||
|
||||
/** 运动区域(归一化坐标) */
|
||||
data class MotionRegion(
|
||||
val left: Float,
|
||||
val top: Float,
|
||||
val right: Float,
|
||||
val bottom: Float,
|
||||
) {
|
||||
val centerX: Float get() = (left + right) / 2f
|
||||
val centerY: Float get() = (top + bottom) / 2f
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.example.observer.detection
|
||||
|
||||
fun iou(a: DetectionResult, b: DetectionResult): Float {
|
||||
val x0 = maxOf(a.left, b.left)
|
||||
val y0 = maxOf(a.top, b.top)
|
||||
val x1 = minOf(a.right, b.right)
|
||||
val y1 = minOf(a.bottom, b.bottom)
|
||||
if (x1 <= x0 || y1 <= y0) return 0f
|
||||
val inter = (x1 - x0) * (y1 - y0)
|
||||
val union = a.width * a.height + b.width * b.height - inter
|
||||
return if (union <= 0f) 0f else inter / union
|
||||
}
|
||||
|
||||
fun nms(boxes: List<DetectionResult>, iouThreshold: Float): List<DetectionResult> {
|
||||
val sorted = boxes.sortedByDescending { it.score }
|
||||
val kept = mutableListOf<DetectionResult>()
|
||||
for (b in sorted) {
|
||||
if (kept.none { iou(b, it) > iouThreshold }) kept += b
|
||||
}
|
||||
return kept
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.example.observer.detection
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import org.tensorflow.lite.Interpreter
|
||||
import org.tensorflow.lite.gpu.GpuDelegate
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* YOLOv8n 端侧推理实现。
|
||||
* 模型输出布局(ultralytics tflite 导出):[1, 4 + nc, 8400],
|
||||
* cx/cy/w/h 已归一化,类别得分已过 sigmoid;按列平铺:index = c * 8400 + anchor。
|
||||
*/
|
||||
class TFLiteDetector private constructor(
|
||||
private val interpreter: Interpreter,
|
||||
private val labels: List<String>,
|
||||
) : Detector {
|
||||
|
||||
@Volatile var confThreshold: Float = 0.40f
|
||||
|
||||
@Volatile var habitatThreshold: Float = 0.35f
|
||||
|
||||
private val scaledBitmap = Bitmap.createBitmap(INPUT_SIZE, INPUT_SIZE, Bitmap.Config.ARGB_8888)
|
||||
private val scaleCanvas = Canvas(scaledBitmap)
|
||||
private val scaleMatrix = Matrix()
|
||||
private val pixels = IntArray(INPUT_SIZE * INPUT_SIZE)
|
||||
|
||||
private val inputBuffer: ByteBuffer =
|
||||
ByteBuffer.allocateDirect(1 * INPUT_SIZE * INPUT_SIZE * 3 * 4)
|
||||
.order(ByteOrder.nativeOrder())
|
||||
|
||||
private val outputFloats = FloatArray((4 + NUM_CLASSES) * NUM_ANCHORS)
|
||||
|
||||
override fun detect(bitmap: Bitmap): List<DetectionResult> {
|
||||
preprocess(bitmap)
|
||||
interpreter.run(inputBuffer, outputFloats)
|
||||
return postprocess()
|
||||
}
|
||||
|
||||
private fun preprocess(bitmap: Bitmap) {
|
||||
// CENTER_CROP 缩放保持宽高比
|
||||
val scale = maxOf(INPUT_SIZE.toFloat() / bitmap.width, INPUT_SIZE.toFloat() / bitmap.height)
|
||||
val dx = (INPUT_SIZE - bitmap.width * scale) / 2f
|
||||
val dy = (INPUT_SIZE - bitmap.height * scale) / 2f
|
||||
scaleMatrix.reset()
|
||||
scaleMatrix.setScale(scale, scale)
|
||||
scaleMatrix.postTranslate(dx, dy)
|
||||
scaleCanvas.drawBitmap(bitmap, scaleMatrix, null)
|
||||
|
||||
inputBuffer.rewind()
|
||||
scaledBitmap.getPixels(pixels, 0, INPUT_SIZE, 0, 0, INPUT_SIZE, INPUT_SIZE)
|
||||
for (p in pixels) {
|
||||
inputBuffer.putFloat(((p shr 16) and 0xFF) / 255f)
|
||||
inputBuffer.putFloat(((p shr 8) and 0xFF) / 255f)
|
||||
inputBuffer.putFloat((p and 0xFF) / 255f)
|
||||
}
|
||||
}
|
||||
|
||||
private fun postprocess(): List<DetectionResult> {
|
||||
val boxes = ArrayList<DetectionResult>(8)
|
||||
for (a in 0 until NUM_ANCHORS) {
|
||||
val cx = outputFloats[a]
|
||||
val cy = outputFloats[NUM_ANCHORS + a]
|
||||
val w = outputFloats[2 * NUM_ANCHORS + a]
|
||||
val h = outputFloats[3 * NUM_ANCHORS + a]
|
||||
var bestCls = 0
|
||||
var bestScore = 0f
|
||||
for (c in 0 until NUM_CLASSES) {
|
||||
val s = outputFloats[(4 + c) * NUM_ANCHORS + a]
|
||||
if (s > bestScore) {
|
||||
bestScore = s
|
||||
bestCls = c
|
||||
}
|
||||
}
|
||||
val label = labels.getOrElse(bestCls) { "unknown" }
|
||||
// cover 用独立阈值;动物类保留低分池,供运动检测提升
|
||||
val threshold = if (label == DetectionResult.HABITAT_LABEL) habitatThreshold else MIN_SCORE
|
||||
if (bestScore < threshold) continue
|
||||
boxes += DetectionResult(
|
||||
label = label,
|
||||
score = bestScore,
|
||||
left = (cx - w / 2f).coerceIn(0f, 1f),
|
||||
top = (cy - h / 2f).coerceIn(0f, 1f),
|
||||
right = (cx + w / 2f).coerceIn(0f, 1f),
|
||||
bottom = (cy + h / 2f).coerceIn(0f, 1f),
|
||||
)
|
||||
}
|
||||
return nms(boxes, IOU_THRESHOLD).take(MAX_DETECTIONS)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val INPUT_SIZE = 320
|
||||
|
||||
/** 动物类最低保留分数:低于此分不输出(低分候选由运动检测提升显示) */
|
||||
const val MIN_SCORE = 0.20f
|
||||
private const val NUM_CLASSES = 4
|
||||
private const val NUM_ANCHORS = 8400
|
||||
private const val IOU_THRESHOLD = 0.45f
|
||||
private const val MAX_DETECTIONS = 20
|
||||
private const val MODEL_ASSET = "model.tflite"
|
||||
private const val LABELS_ASSET = "labels.txt"
|
||||
|
||||
/** 模型缺失或加载失败返回 null(App 降级为仅预览) */
|
||||
fun create(context: Context): TFLiteDetector? {
|
||||
return try {
|
||||
val options = Interpreter.Options().apply {
|
||||
setNumThreads(4)
|
||||
try {
|
||||
addDelegate(GpuDelegate())
|
||||
} catch (_: Throwable) {
|
||||
// GPU 不可用,回退 CPU
|
||||
}
|
||||
}
|
||||
val interpreter = Interpreter(loadModelFile(context), options)
|
||||
val labels = context.assets.open(LABELS_ASSET).bufferedReader().readLines()
|
||||
TFLiteDetector(interpreter, labels)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadModelFile(context: Context): ByteBuffer {
|
||||
val bytes = context.assets.open(MODEL_ASSET).use { it.readBytes() }
|
||||
return ByteBuffer.allocateDirect(bytes.size).order(ByteOrder.nativeOrder())
|
||||
.apply { put(bytes); rewind() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.example.observer.distance
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.hardware.camera2.CameraManager
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* 单目距离估计(针孔模型):距离 = 焦距px × 参考体型 / 框高px。
|
||||
* 误差预期 ±30%(5~50m);鱼类目标受折射影响、生境区域按植被高度估算,均仅供参考。
|
||||
*/
|
||||
class DistanceEstimator(context: Context) {
|
||||
|
||||
private val cameraManager =
|
||||
context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
|
||||
|
||||
private val focalPxCache = HashMap<String, Float>()
|
||||
|
||||
// 参考体型(米)
|
||||
private val speciesSizeM = mapOf(
|
||||
"pheasant" to 0.45f, // 身高
|
||||
"hare" to 0.45f, // 身长
|
||||
"dove" to 0.30f, // 体长
|
||||
"fish" to 1.00f, // 典型可见体长(误差大)
|
||||
"cover" to 0.50f, // 植被高度(水面区域误差大)
|
||||
)
|
||||
|
||||
fun estimate(
|
||||
label: String,
|
||||
boxHeightNorm: Float,
|
||||
imageHeightPx: Int,
|
||||
cameraId: String?,
|
||||
): Float? {
|
||||
val realH = speciesSizeM[label] ?: return null
|
||||
val boxH = boxHeightNorm * imageHeightPx
|
||||
if (boxH < 8f) return null // 过小目标不估算
|
||||
val focalPx = focalPx(imageHeightPx, cameraId)
|
||||
if (focalPx <= 0f) return null
|
||||
return (focalPx * realH / boxH).roundToInt().toFloat()
|
||||
}
|
||||
|
||||
/** focal_px = focal_mm × (imageHeightPx / sensorHeightMm) */
|
||||
private fun focalPx(imageHeightPx: Int, cameraId: String?): Float {
|
||||
val key = "$cameraId:$imageHeightPx"
|
||||
focalPxCache[key]?.let { return it }
|
||||
val id = cameraId ?: return -1f
|
||||
val value = try {
|
||||
val c = cameraManager.getCameraCharacteristics(id)
|
||||
val focalMm = c.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)
|
||||
?.firstOrNull()
|
||||
val sensor = c.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE)
|
||||
if (focalMm != null && sensor != null) {
|
||||
focalMm * imageHeightPx / sensor.height
|
||||
} else {
|
||||
-1f
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
-1f
|
||||
}
|
||||
focalPxCache[key] = value
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.example.observer.overlay
|
||||
|
||||
import android.graphics.Paint
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.observer.detection.CoordinateMapper
|
||||
import com.example.observer.detection.DetectionResult
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val speciesColors = mapOf(
|
||||
"pheasant" to Color(0xFFE53935),
|
||||
"hare" to Color(0xFF1E88E5),
|
||||
"dove" to Color(0xFF8E24AA),
|
||||
"fish" to Color(0xFF00ACC1),
|
||||
"cover" to Color(0xFFFDD835),
|
||||
)
|
||||
|
||||
private val speciesLabels = mapOf(
|
||||
"pheasant" to "野鸡",
|
||||
"hare" to "野兔",
|
||||
"dove" to "斑鸠",
|
||||
"fish" to "鱼",
|
||||
"cover" to "疑似区域",
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DetectionOverlay(
|
||||
results: List<DetectionResult>,
|
||||
rotation: Int,
|
||||
imageW: Int,
|
||||
imageH: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Canvas(modifier = modifier) {
|
||||
val viewW = size.width.toInt()
|
||||
val viewH = size.height.toInt()
|
||||
val textPaint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
textSize = 30f
|
||||
}
|
||||
results.forEach { r ->
|
||||
val rect = CoordinateMapper.mapToView(
|
||||
r.left, r.top, r.right, r.bottom,
|
||||
rotation, imageW, imageH, viewW, viewH,
|
||||
)
|
||||
val color = speciesColors[r.label] ?: Color.White
|
||||
val stroke = Stroke(
|
||||
width = 6.dp.toPx(),
|
||||
pathEffect = if (r.isHabitat) {
|
||||
PathEffect.dashPathEffect(floatArrayOf(20.dp.toPx(), 12.dp.toPx()))
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
drawRect(
|
||||
color = color,
|
||||
topLeft = Offset(rect.left, rect.top),
|
||||
size = Size(rect.width, rect.height),
|
||||
style = stroke,
|
||||
)
|
||||
textPaint.color = color.toArgb()
|
||||
val text = buildString {
|
||||
append(speciesLabels[r.label] ?: r.label)
|
||||
append(" ${(r.score * 100).toInt()}%")
|
||||
r.distanceM?.let { append(" · 约${it.roundToInt()}m") }
|
||||
}
|
||||
val labelTop = (rect.top - textPaint.textSize - 6).coerceAtLeast(0f)
|
||||
drawContext.canvas.nativeCanvas.drawText(
|
||||
text,
|
||||
rect.left.coerceAtLeast(0f) + 4f,
|
||||
labelTop + textPaint.textSize,
|
||||
textPaint,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.example.observer.reminder
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.media.ToneGenerator
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
|
||||
class Reminder(context: Context) {
|
||||
|
||||
@Volatile var vibrateEnabled: Boolean = true
|
||||
|
||||
@Volatile var soundEnabled: Boolean = true
|
||||
|
||||
@Volatile var habitatAlertEnabled: Boolean = true
|
||||
|
||||
private val vibrator: Vibrator? =
|
||||
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
|
||||
private val toneGenerator: ToneGenerator? = try {
|
||||
ToneGenerator(AudioManager.STREAM_NOTIFICATION, 60)
|
||||
} catch (e: RuntimeException) {
|
||||
null
|
||||
}
|
||||
|
||||
private var lastAlertLabel: String? = null
|
||||
private var lastAlertAt = 0L
|
||||
|
||||
/** 同类目标 10s 内只提醒一次;生境区域(cover)提醒方式与动物检测区分 */
|
||||
fun onDetected(label: String) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (label == lastAlertLabel && now - lastAlertAt < 10_000) return
|
||||
lastAlertAt = now
|
||||
lastAlertLabel = label
|
||||
|
||||
val isHabitat = label == "cover"
|
||||
if (isHabitat && !habitatAlertEnabled) return
|
||||
|
||||
if (vibrateEnabled) vibrate(isHabitat)
|
||||
if (soundEnabled) playTone(isHabitat)
|
||||
}
|
||||
|
||||
private fun vibrate(isHabitat: Boolean) {
|
||||
val v = vibrator ?: return
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
val effect = if (isHabitat) {
|
||||
VibrationEffect.createWaveform(longArrayOf(0, 150, 80, 150), -1)
|
||||
} else {
|
||||
VibrationEffect.createOneShot(200, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
}
|
||||
v.vibrate(effect)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
v.vibrate(if (isHabitat) longArrayOf(0, 150, 80, 150) else longArrayOf(0, 200), -1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun playTone(isHabitat: Boolean) {
|
||||
val tone = toneGenerator ?: return
|
||||
if (isHabitat) {
|
||||
// 双短音:生境预警
|
||||
tone.startTone(ToneGenerator.TONE_PROP_BEEP2, 120)
|
||||
tone.startTone(ToneGenerator.TONE_PROP_BEEP2, 120)
|
||||
} else {
|
||||
tone.startTone(ToneGenerator.TONE_PROP_BEEP2, 200)
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
toneGenerator?.release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.example.observer.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.example.observer.ui.camera.CameraScreen
|
||||
import com.example.observer.ui.settings.SettingsScreen
|
||||
import com.example.observer.ui.theme.ObserverTheme
|
||||
|
||||
@Composable
|
||||
fun ObserverApp() {
|
||||
ObserverTheme {
|
||||
var showSettings by rememberSaveable { mutableStateOf(false) }
|
||||
if (showSettings) {
|
||||
SettingsScreen(onBack = { showSettings = false })
|
||||
} else {
|
||||
CameraScreen(onOpenSettings = { showSettings = true })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package com.example.observer.ui.camera
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.example.observer.ObserverApp
|
||||
import com.example.observer.camera.CameraController
|
||||
import com.example.observer.camera.FrameAnalyzer
|
||||
import com.example.observer.camera.MotionDetector
|
||||
import com.example.observer.data.AppSettings
|
||||
import com.example.observer.data.DetectMode
|
||||
import com.example.observer.overlay.DetectionOverlay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@Composable
|
||||
fun CameraScreen(onOpenSettings: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val container = (context.applicationContext as ObserverApp).container
|
||||
val viewModel: CameraViewModel = viewModel(
|
||||
factory = remember { CameraViewModelFactory(container) },
|
||||
)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val settings by container.settingsRepository.settings.collectAsStateWithLifecycle(
|
||||
initialValue = AppSettings(),
|
||||
)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val executor = remember { Executors.newSingleThreadExecutor() }
|
||||
val previewView = remember {
|
||||
PreviewView(context).apply {
|
||||
scaleType = PreviewView.ScaleType.FIT_CENTER
|
||||
implementationMode = PreviewView.ImplementationMode.PERFORMANCE
|
||||
}
|
||||
}
|
||||
val cameraController = remember { CameraController(lifecycleOwner, executor) }
|
||||
val motionDetector = remember { MotionDetector() }
|
||||
val analyzer = remember(container.detector) {
|
||||
container.detector?.let { d ->
|
||||
FrameAnalyzer(d, motionDetector) { results, rotation, w, h, motion ->
|
||||
viewModel.onFramesAnalyzed(results, rotation, w, h, motion)
|
||||
}
|
||||
}
|
||||
}
|
||||
val interval by viewModel.analyzerIntervalMs.collectAsState()
|
||||
|
||||
var hasCameraPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted -> hasCameraPermission = granted }
|
||||
var retryKey by remember { mutableIntStateOf(0) }
|
||||
var initFailed by remember { mutableStateOf(false) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
cameraController.onCameraIdChanged = { viewModel.onCameraIdChanged(it) }
|
||||
onDispose { executor.shutdown() }
|
||||
}
|
||||
LaunchedEffect(interval) { analyzer?.intervalMs = interval }
|
||||
LaunchedEffect(retryKey) {
|
||||
cameraController.onInitError = { initFailed = true }
|
||||
if (hasCameraPermission) cameraController.start(previewView, analyzer)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
if (!hasCameraPermission) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
|
||||
if (hasCameraPermission) {
|
||||
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
|
||||
|
||||
if (state.modelReady) {
|
||||
DetectionOverlay(
|
||||
results = state.results,
|
||||
rotation = state.rotation,
|
||||
imageW = state.imageWidthPx,
|
||||
imageH = state.imageHeightPx,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"模型未加载:请将训练好的 model.tflite 放入 app/src/main/assets 后重新构建",
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (initFailed) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.background(Color(0x99000000), RoundedCornerShape(12.dp))
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text("相机初始化失败", color = Color.White)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
TextButton(onClick = {
|
||||
initFailed = false
|
||||
retryKey++
|
||||
}) { Text("重试") }
|
||||
}
|
||||
}
|
||||
|
||||
CameraTopBar(
|
||||
detectMode = settings.detectMode,
|
||||
onCycleMode = {
|
||||
val next = DetectMode.entries[
|
||||
(settings.detectMode.ordinal + 1) % DetectMode.entries.size
|
||||
]
|
||||
scope.launch { container.settingsRepository.setDetectMode(next) }
|
||||
},
|
||||
onSwitchCamera = { cameraController.switchCamera(previewView, analyzer) },
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
CameraBottomBar(
|
||||
vibrateEnabled = settings.vibrateEnabled,
|
||||
onToggleVibrate = {
|
||||
scope.launch {
|
||||
container.settingsRepository.setVibrateEnabled(!settings.vibrateEnabled)
|
||||
}
|
||||
},
|
||||
onOpenSettings = onOpenSettings,
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
} else {
|
||||
PermissionGuide(
|
||||
onRequest = { permissionLauncher.launch(Manifest.permission.CAMERA) },
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CameraTopBar(
|
||||
detectMode: DetectMode,
|
||||
onCycleMode: () -> Unit,
|
||||
onSwitchCamera: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0x99000000))
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = onCycleMode) {
|
||||
Text("频率:${detectMode.label}", color = Color.White)
|
||||
}
|
||||
TextButton(onClick = onSwitchCamera) {
|
||||
Text("切换摄像头", color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CameraBottomBar(
|
||||
vibrateEnabled: Boolean,
|
||||
onToggleVibrate: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0x99000000))
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = onToggleVibrate) {
|
||||
Text(if (vibrateEnabled) "提醒:开" else "提醒:关", color = Color.White)
|
||||
}
|
||||
TextButton(onClick = onOpenSettings) {
|
||||
Text("设置", color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionGuide(onRequest: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
"需要相机权限才能进行实时识别",
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(onClick = onRequest) { Text("授权相机") }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.example.observer.ui.camera
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.observer.AppContainer
|
||||
import com.example.observer.data.DetectMode
|
||||
import com.example.observer.data.SettingsRepository
|
||||
import com.example.observer.detection.DetectionResult
|
||||
import com.example.observer.detection.MotionAggregator
|
||||
import com.example.observer.detection.MotionRegion
|
||||
import com.example.observer.detection.TFLiteDetector
|
||||
import com.example.observer.distance.DistanceEstimator
|
||||
import com.example.observer.reminder.Reminder
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.LinkedHashMap
|
||||
|
||||
data class CameraUiState(
|
||||
val modelReady: Boolean = false,
|
||||
val results: List<DetectionResult> = emptyList(),
|
||||
val rotation: Int = 90,
|
||||
val imageWidthPx: Int = 0,
|
||||
val imageHeightPx: Int = 0,
|
||||
)
|
||||
|
||||
class CameraViewModel(
|
||||
private val detector: TFLiteDetector?,
|
||||
private val distanceEstimator: DistanceEstimator,
|
||||
private val reminder: Reminder,
|
||||
settingsRepository: SettingsRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(CameraUiState(modelReady = detector != null))
|
||||
val state: StateFlow<CameraUiState> = _state.asStateFlow()
|
||||
|
||||
private val _analyzerIntervalMs = MutableStateFlow(DetectMode.CONTINUOUS.intervalMs)
|
||||
val analyzerIntervalMs: StateFlow<Long> = _analyzerIntervalMs.asStateFlow()
|
||||
|
||||
@Volatile
|
||||
private var showDistance = true
|
||||
|
||||
@Volatile
|
||||
private var confThreshold = 0.40f
|
||||
|
||||
@Volatile
|
||||
private var cameraId: String? = null
|
||||
|
||||
private val tracks = LinkedHashMap<String, Track>()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.settings.collect { s ->
|
||||
detector?.confThreshold = s.confThreshold
|
||||
detector?.habitatThreshold = s.habitatThreshold
|
||||
reminder.vibrateEnabled = s.vibrateEnabled
|
||||
reminder.soundEnabled = s.soundEnabled
|
||||
reminder.habitatAlertEnabled = s.habitatAlertEnabled
|
||||
showDistance = s.showDistance
|
||||
confThreshold = s.confThreshold
|
||||
_analyzerIntervalMs.value = s.detectMode.intervalMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onCameraIdChanged(id: String) {
|
||||
cameraId = id
|
||||
}
|
||||
|
||||
/** 帧分析回调(分析线程调用,需线程安全) */
|
||||
fun onFramesAnalyzed(
|
||||
results: List<DetectionResult>,
|
||||
rotation: Int,
|
||||
imageWidthPx: Int,
|
||||
imageHeightPx: Int,
|
||||
motionRegions: List<MotionRegion>,
|
||||
) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val byKey = HashMap<String, DetectionResult>(results.size)
|
||||
results.forEach { byKey[keyOf(it)] = it }
|
||||
|
||||
synchronized(this) {
|
||||
// 过期目标清除(消失 2s 后移除)
|
||||
val expired = tracks.entries
|
||||
.filter { it.key !in byKey && now - it.value.lastSeenMs > 2000 }
|
||||
.map { it.key }
|
||||
expired.forEach { tracks.remove(it) }
|
||||
|
||||
byKey.forEach { (k, r) ->
|
||||
val t = tracks[k]
|
||||
tracks[k] = if (t == null) Track(now, now, r) else Track(t.firstSeenMs, now, r)
|
||||
}
|
||||
if (tracks.size > MAX_TRACKS) {
|
||||
val oldest = tracks.entries.sortedBy { it.value.lastSeenMs }.take(tracks.size - MAX_TRACKS)
|
||||
oldest.forEach { tracks.remove(it.key) }
|
||||
}
|
||||
|
||||
// 目标停留 ≥ 0.5s 才展示;消失 2s 内不闪断
|
||||
val visible = tracks.values
|
||||
.filter { now - it.firstSeenMs >= 500 && now - it.lastSeenMs <= 2000 }
|
||||
.filter { shouldShow(it.result, motionRegions) }
|
||||
.map { t ->
|
||||
val r = t.result
|
||||
val boosted = r.score < confThreshold &&
|
||||
motionRegions.any { MotionAggregator.centerInRegion(r, it) }
|
||||
val distance = if (showDistance) {
|
||||
distanceEstimator.estimate(r.label, r.height, imageHeightPx, cameraId)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
r.copy(
|
||||
score = if (boosted) minOf(r.score + MOTION_BOOST, 1f) else r.score,
|
||||
distanceM = distance,
|
||||
)
|
||||
}
|
||||
|
||||
// 提醒:刚变为可见的目标(防重复由 Reminder 按类别 10s 控制)
|
||||
tracks.values.forEach { t ->
|
||||
if (!shouldShow(t.result, motionRegions)) return@forEach
|
||||
val age = now - t.firstSeenMs
|
||||
if (age in 500..2100 && now - t.lastSeenMs <= 300) {
|
||||
reminder.onDetected(t.result.label)
|
||||
}
|
||||
}
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
results = visible,
|
||||
rotation = rotation,
|
||||
imageWidthPx = imageWidthPx,
|
||||
imageHeightPx = imageHeightPx,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示判定:cover 已按生境阈值过滤;动物分低于阈值时,
|
||||
* 仅当与运动区域重叠(低分候选)才提升显示。
|
||||
*/
|
||||
private fun shouldShow(r: DetectionResult, motionRegions: List<MotionRegion>): Boolean {
|
||||
if (r.isHabitat) return true
|
||||
if (r.score >= confThreshold) return true
|
||||
if (r.score < TFLiteDetector.MIN_SCORE) return false
|
||||
return motionRegions.any { MotionAggregator.centerInRegion(r, it) }
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
reminder.release()
|
||||
}
|
||||
|
||||
private fun keyOf(r: DetectionResult): String =
|
||||
"${r.label}:${(r.centerX * 10).toInt()}:${(r.centerY * 10).toInt()}"
|
||||
|
||||
private data class Track(
|
||||
val firstSeenMs: Long,
|
||||
val lastSeenMs: Long,
|
||||
val result: DetectionResult,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val MAX_TRACKS = 30
|
||||
|
||||
/** 低分目标与运动区域重叠时的置信度提升量 */
|
||||
private const val MOTION_BOOST = 0.15f
|
||||
}
|
||||
}
|
||||
|
||||
class CameraViewModelFactory(private val container: AppContainer) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(CameraViewModel::class.java)) {
|
||||
return CameraViewModel(
|
||||
detector = container.detector as? TFLiteDetector,
|
||||
distanceEstimator = container.distanceEstimator,
|
||||
reminder = container.reminder,
|
||||
settingsRepository = container.settingsRepository,
|
||||
) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.example.observer.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.example.observer.ObserverApp
|
||||
import com.example.observer.data.AppSettings
|
||||
import com.example.observer.data.DetectMode
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val repository = (context.applicationContext as ObserverApp).container.settingsRepository
|
||||
val settings by repository.settings.collectAsStateWithLifecycle(initialValue = AppSettings())
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("设置") },
|
||||
navigationIcon = {
|
||||
TextButton(onClick = onBack) { Text("返回") }
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("动物识别阈值:${"%.2f".format(settings.confThreshold)}")
|
||||
Slider(
|
||||
value = settings.confThreshold,
|
||||
onValueChange = { scope.launch { repository.setConfThreshold(it) } },
|
||||
valueRange = 0.2f..0.7f,
|
||||
)
|
||||
|
||||
Text("生境区域阈值:${"%.2f".format(settings.habitatThreshold)}")
|
||||
Slider(
|
||||
value = settings.habitatThreshold,
|
||||
onValueChange = { scope.launch { repository.setHabitatThreshold(it) } },
|
||||
valueRange = 0.2f..0.7f,
|
||||
)
|
||||
|
||||
Text("检测频率")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
DetectMode.entries.forEach { mode ->
|
||||
FilterChip(
|
||||
selected = settings.detectMode == mode,
|
||||
onClick = { scope.launch { repository.setDetectMode(mode) } },
|
||||
label = { Text(mode.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SwitchRow("震动提醒", settings.vibrateEnabled) {
|
||||
scope.launch { repository.setVibrateEnabled(it) }
|
||||
}
|
||||
SwitchRow("提示音", settings.soundEnabled) {
|
||||
scope.launch { repository.setSoundEnabled(it) }
|
||||
}
|
||||
SwitchRow("距离标注", settings.showDistance) {
|
||||
scope.launch { repository.setShowDistance(it) }
|
||||
}
|
||||
SwitchRow("生境区域预警", settings.habitatAlertEnabled) {
|
||||
scope.launch { repository.setHabitatAlertEnabled(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwitchRow(title: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(title)
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.example.observer.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = Color(0xFF66BB6A),
|
||||
onPrimary = Color(0xFF00391F),
|
||||
secondary = Color(0xFF80CBC4),
|
||||
surface = Color(0xFF1C1C1C),
|
||||
background = Color(0xFF101010),
|
||||
onBackground = Color(0xFFE4E4E4),
|
||||
onSurface = Color(0xFFE4E4E4),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ObserverTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(colorScheme = DarkColors, content = content)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#1B5E20"
|
||||
android:pathData="M54,54m-54,0a54,54 0,1 1,108 0a54,54 0,1 1,-108 0" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="6"
|
||||
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
|
||||
<path
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="4"
|
||||
android:pathData="M54,22 L54,34 M54,74 L54,86 M22,54 L34,54 M74,54 L86,54" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,54m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">野视</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<resources>
|
||||
<style name="Theme.Observer" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/black</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.example.observer.detection
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class CoordinateMapperTest {
|
||||
|
||||
@Test
|
||||
fun rotation90_center_mapsToViewCenter() {
|
||||
// 横屏图像 1280x720,旋转 90 后竖屏显示 720x1280,视图 1080x1920(等比,无偏移)
|
||||
val rect = CoordinateMapper.mapToView(
|
||||
normLeft = 0.4f, normTop = 0.4f, normRight = 0.6f, normBottom = 0.6f,
|
||||
rotation = 90, imageW = 1280, imageH = 720,
|
||||
viewW = 1080, viewH = 1920,
|
||||
)
|
||||
assertEquals(540f, rect.centerX, 1f)
|
||||
assertEquals(960f, rect.centerY, 1f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rotation90_corner_appliesFitCenterOffset() {
|
||||
// 视图 1080x2400,竖屏图像 720x1280,scale=1.5,纵向偏移 (2400-1920)/2=240
|
||||
val rect = CoordinateMapper.mapToView(
|
||||
normLeft = 0f, normTop = 0f, normRight = 0.5f, normBottom = 0.5f,
|
||||
rotation = 90, imageW = 1280, imageH = 720,
|
||||
viewW = 1080, viewH = 2400,
|
||||
)
|
||||
// 旋转后:x0=1-bottom=0.5, y0=left=0, x1=1-top=1, y1=right=0.5
|
||||
assertEquals(540f, rect.left, 1f) // 0.5 * 720 * 1.5
|
||||
assertEquals(240f, rect.top, 1f) // 0 * 1280 * 1.5 + 240
|
||||
assertEquals(1080f, rect.right, 1f) // 1 * 720 * 1.5
|
||||
assertEquals(1200f, rect.bottom, 1f) // 0.5 * 1280 * 1.5 + 240
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rotation0_keepsCoordinates() {
|
||||
val rect = CoordinateMapper.mapToView(
|
||||
normLeft = 0.2f, normTop = 0.3f, normRight = 0.5f, normBottom = 0.7f,
|
||||
rotation = 0, imageW = 1080, imageH = 1920,
|
||||
viewW = 1080, viewH = 1920,
|
||||
)
|
||||
assertEquals(216f, rect.left, 1f)
|
||||
assertEquals(576f, rect.top, 1f)
|
||||
assertEquals(540f, rect.right, 1f)
|
||||
assertEquals(1344f, rect.bottom, 1f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rotation180_flipsBothAxes() {
|
||||
val rect = CoordinateMapper.mapToView(
|
||||
normLeft = 0.2f, normTop = 0.2f, normRight = 0.4f, normBottom = 0.4f,
|
||||
rotation = 180, imageW = 1080, imageH = 1920,
|
||||
viewW = 1080, viewH = 1920,
|
||||
)
|
||||
// 翻转:x'=1-x, y'=1-y
|
||||
assertEquals(648f, rect.left, 1f) // (1-0.4) * 1080
|
||||
assertEquals(1152f, rect.top, 1f) // (1-0.4) * 1920
|
||||
assertEquals(864f, rect.right, 1f) // (1-0.2) * 1080
|
||||
assertEquals(1536f, rect.bottom, 1f) // (1-0.2) * 1920
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.example.observer.detection
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MotionAggregatorTest {
|
||||
|
||||
// 96x64 图,8x8 块 → 每块 12x8 像素
|
||||
|
||||
@Test
|
||||
fun noMotion_returnsEmpty() {
|
||||
val diff = IntArray(96 * 64)
|
||||
assertTrue(MotionAggregator.aggregate(diff, 96, 64).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun singleBlockMotion_detectsRegion() {
|
||||
val diff = IntArray(96 * 64)
|
||||
// 块 (2,3):x 24..35, y 24..31,全部置 1
|
||||
for (y in 24 until 32) {
|
||||
for (x in 24 until 36) diff[y * 96 + x] = 1
|
||||
}
|
||||
val regions = MotionAggregator.aggregate(diff, 96, 64)
|
||||
assertEquals(1, regions.size)
|
||||
val r = regions[0]
|
||||
assertTrue(r.left <= 24f / 96f && r.right >= 36f / 96f)
|
||||
assertTrue(r.top <= 24f / 64f && r.bottom >= 32f / 64f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun twoSeparateMotions_detectsTwoRegions() {
|
||||
val diff = IntArray(96 * 64)
|
||||
for (y in 0 until 8) for (x in 0 until 12) diff[y * 96 + x] = 1
|
||||
for (y in 48 until 64) for (x in 72 until 96) diff[y * 96 + x] = 1
|
||||
val regions = MotionAggregator.aggregate(diff, 96, 64)
|
||||
assertEquals(2, regions.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun globalNoise_filteredOut() {
|
||||
val diff = IntArray(96 * 64)
|
||||
// 全屏散点噪声(随机块),但无连续连通域
|
||||
val rnd = java.util.Random(42)
|
||||
for (i in diff.indices) if (rnd.nextFloat() < 0.1f) diff[i] = 1
|
||||
val regions = MotionAggregator.aggregate(diff, 96, 64)
|
||||
assertTrue(regions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun centerInRegion_matches() {
|
||||
val box = DetectionResult(
|
||||
label = "hare",
|
||||
score = 0.30f,
|
||||
left = 0.2f,
|
||||
top = 0.3f,
|
||||
right = 0.4f,
|
||||
bottom = 0.5f,
|
||||
)
|
||||
val region = MotionRegion(0.1f, 0.2f, 0.5f, 0.6f)
|
||||
assertTrue(MotionAggregator.centerInRegion(box, region))
|
||||
val outside = MotionRegion(0.6f, 0.7f, 0.9f, 0.9f)
|
||||
assertFalse(MotionAggregator.centerInRegion(box, outside))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.example.observer.detection
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NmsTest {
|
||||
|
||||
private fun box(l: Float, t: Float, r: Float, b: Float, score: Float, label: String = "x") =
|
||||
DetectionResult(label, score, l, t, r, b)
|
||||
|
||||
@Test
|
||||
fun overlappingBoxes_keepHighestScore() {
|
||||
val a = box(0.1f, 0.1f, 0.5f, 0.5f, 0.8f)
|
||||
val b = box(0.12f, 0.12f, 0.52f, 0.52f, 0.6f)
|
||||
val result = nms(listOf(a, b), 0.45f)
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(0.8f, result[0].score, 1e-6f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun separateBoxes_bothKept() {
|
||||
val a = box(0.1f, 0.1f, 0.3f, 0.3f, 0.8f)
|
||||
val b = box(0.7f, 0.7f, 0.9f, 0.9f, 0.6f)
|
||||
val result = nms(listOf(a, b), 0.45f)
|
||||
assertEquals(2, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lowScoreBox_suppressedByHigherScore() {
|
||||
val a = box(0.1f, 0.1f, 0.5f, 0.5f, 0.9f)
|
||||
val b = box(0.1f, 0.1f, 0.5f, 0.5f, 0.5f)
|
||||
val result = nms(listOf(b, a), 0.45f)
|
||||
assertEquals(1, result.size)
|
||||
assertTrue(result[0].score > 0.5f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iou_nonOverlapping_isZero() {
|
||||
val a = box(0.0f, 0.0f, 0.2f, 0.2f, 1f)
|
||||
val b = box(0.8f, 0.8f, 1f, 1f, 1f)
|
||||
assertEquals(0f, iou(a, b), 1e-6f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iou_identicalBoxes_isOne() {
|
||||
val a = box(0.1f, 0.1f, 0.5f, 0.5f, 1f)
|
||||
assertEquals(1f, iou(a, a), 1e-6f)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user