From b9934c996d6c625f3ad745c068c80a74a326ec1f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com>
Date: Thu, 20 Aug 2026 13:11:57 +0800
Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=20observer=20?=
=?UTF-8?q?=E9=A1=B9=E7=9B=AE:=E7=BA=AF=E4=BB=A3=E7=A0=81,=E4=B8=8D?=
=?UTF-8?q?=E5=90=AB=E6=9D=83=E9=87=8D=E4=B8=8E=E8=AE=AD=E7=BB=83=E6=95=B0?=
=?UTF-8?q?=E6=8D=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 12 +
app/build.gradle.kts | 80 +++
app/proguard-rules.pro | 3 +
app/src/main/AndroidManifest.xml | 26 +
app/src/main/assets/labels.txt | 4 +
.../java/com/example/observer/MainActivity.kt | 17 +
.../java/com/example/observer/ObserverApp.kt | 26 +
.../observer/camera/CameraController.kt | 82 +++
.../example/observer/camera/FrameAnalyzer.kt | 38 ++
.../example/observer/camera/MotionDetector.kt | 60 ++
.../observer/data/SettingsRepository.kt | 75 +++
.../observer/detection/CoordinateMapper.kt | 54 ++
.../observer/detection/DetectionResult.kt | 21 +
.../example/observer/detection/Detector.kt | 8 +
.../observer/detection/MotionAggregator.kt | 103 ++++
.../observer/detection/MotionRegion.kt | 12 +
.../com/example/observer/detection/Nms.kt | 21 +
.../observer/detection/TFLiteDetector.kt | 131 +++++
.../observer/distance/DistanceEstimator.kt | 63 +++
.../observer/overlay/DetectionOverlay.kt | 85 +++
.../com/example/observer/reminder/Reminder.kt | 74 +++
.../com/example/observer/ui/ObserverApp.kt | 22 +
.../observer/ui/camera/CameraScreen.kt | 244 +++++++++
.../observer/ui/camera/CameraViewModel.kt | 186 +++++++
.../observer/ui/settings/SettingsScreen.kt | 109 ++++
.../com/example/observer/ui/theme/Theme.kt | 21 +
app/src/main/res/drawable/ic_launcher.xml | 21 +
app/src/main/res/values/strings.xml | 3 +
app/src/main/res/values/themes.xml | 7 +
.../detection/CoordinateMapperTest.kt | 61 +++
.../detection/MotionAggregatorTest.kt | 66 +++
.../com/example/observer/detection/NmsTest.kt | 50 ++
build.gradle.kts | 5 +
docs/01-技术方案.md | 318 +++++++++++
docs/02-项目功能文档.md | 193 +++++++
docs/03-技术实现文档.md | 512 ++++++++++++++++++
gradle.properties | 4 +
gradle/libs.versions.toml | 39 ++
gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 47505 bytes
gradle/wrapper/gradle-wrapper.properties | 9 +
gradlew | 248 +++++++++
gradlew.bat | 82 +++
settings.gradle.kts | 24 +
training/auto_label.py | 202 +++++++
training/auto_label_for_training.py | 120 ++++
training/auto_label_v2.py | 234 ++++++++
training/clip_generate_labels.py | 207 +++++++
training/download_data.py | 300 ++++++++++
training/filter_images.py | 112 ++++
training/filter_images_clip.py | 130 +++++
training/train_yolov8n.py | 59 ++
training/zero_shot_detection.py | 154 ++++++
52 files changed, 4737 insertions(+)
create mode 100644 .gitignore
create mode 100644 app/build.gradle.kts
create mode 100644 app/proguard-rules.pro
create mode 100644 app/src/main/AndroidManifest.xml
create mode 100644 app/src/main/assets/labels.txt
create mode 100644 app/src/main/java/com/example/observer/MainActivity.kt
create mode 100644 app/src/main/java/com/example/observer/ObserverApp.kt
create mode 100644 app/src/main/java/com/example/observer/camera/CameraController.kt
create mode 100644 app/src/main/java/com/example/observer/camera/FrameAnalyzer.kt
create mode 100644 app/src/main/java/com/example/observer/camera/MotionDetector.kt
create mode 100644 app/src/main/java/com/example/observer/data/SettingsRepository.kt
create mode 100644 app/src/main/java/com/example/observer/detection/CoordinateMapper.kt
create mode 100644 app/src/main/java/com/example/observer/detection/DetectionResult.kt
create mode 100644 app/src/main/java/com/example/observer/detection/Detector.kt
create mode 100644 app/src/main/java/com/example/observer/detection/MotionAggregator.kt
create mode 100644 app/src/main/java/com/example/observer/detection/MotionRegion.kt
create mode 100644 app/src/main/java/com/example/observer/detection/Nms.kt
create mode 100644 app/src/main/java/com/example/observer/detection/TFLiteDetector.kt
create mode 100644 app/src/main/java/com/example/observer/distance/DistanceEstimator.kt
create mode 100644 app/src/main/java/com/example/observer/overlay/DetectionOverlay.kt
create mode 100644 app/src/main/java/com/example/observer/reminder/Reminder.kt
create mode 100644 app/src/main/java/com/example/observer/ui/ObserverApp.kt
create mode 100644 app/src/main/java/com/example/observer/ui/camera/CameraScreen.kt
create mode 100644 app/src/main/java/com/example/observer/ui/camera/CameraViewModel.kt
create mode 100644 app/src/main/java/com/example/observer/ui/settings/SettingsScreen.kt
create mode 100644 app/src/main/java/com/example/observer/ui/theme/Theme.kt
create mode 100644 app/src/main/res/drawable/ic_launcher.xml
create mode 100644 app/src/main/res/values/strings.xml
create mode 100644 app/src/main/res/values/themes.xml
create mode 100644 app/src/test/java/com/example/observer/detection/CoordinateMapperTest.kt
create mode 100644 app/src/test/java/com/example/observer/detection/MotionAggregatorTest.kt
create mode 100644 app/src/test/java/com/example/observer/detection/NmsTest.kt
create mode 100644 build.gradle.kts
create mode 100644 docs/01-技术方案.md
create mode 100644 docs/02-项目功能文档.md
create mode 100644 docs/03-技术实现文档.md
create mode 100644 gradle.properties
create mode 100644 gradle/libs.versions.toml
create mode 100644 gradle/wrapper/gradle-wrapper.jar
create mode 100644 gradle/wrapper/gradle-wrapper.properties
create mode 100755 gradlew
create mode 100644 gradlew.bat
create mode 100644 settings.gradle.kts
create mode 100644 training/auto_label.py
create mode 100644 training/auto_label_for_training.py
create mode 100644 training/auto_label_v2.py
create mode 100644 training/clip_generate_labels.py
create mode 100644 training/download_data.py
create mode 100644 training/filter_images.py
create mode 100644 training/filter_images_clip.py
create mode 100644 training/train_yolov8n.py
create mode 100644 training/zero_shot_detection.py
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6e9ac90
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+*.iml
+.gradle/
+local.properties
+.idea/
+build/
+captures/
+.externalNativeBuild/
+.cxx/
+.DS_Store
+training/datasets/
+training/venv/
+*.pt
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..2767ee3
--- /dev/null
+++ b/app/build.gradle.kts
@@ -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)
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..2927948
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,3 @@
+# TensorFlow Lite
+-keep class org.tensorflow.** { *; }
+-dontwarn org.tensorflow.**
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..6879cfb
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/labels.txt b/app/src/main/assets/labels.txt
new file mode 100644
index 0000000..0544cfa
--- /dev/null
+++ b/app/src/main/assets/labels.txt
@@ -0,0 +1,4 @@
+pheasant
+hare
+dove
+fish
diff --git a/app/src/main/java/com/example/observer/MainActivity.kt b/app/src/main/java/com/example/observer/MainActivity.kt
new file mode 100644
index 0000000..6366940
--- /dev/null
+++ b/app/src/main/java/com/example/observer/MainActivity.kt
@@ -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()
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/observer/ObserverApp.kt b/app/src/main/java/com/example/observer/ObserverApp.kt
new file mode 100644
index 0000000..d9fb221
--- /dev/null
+++ b/app/src/main/java/com/example/observer/ObserverApp.kt
@@ -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)
+}
diff --git a/app/src/main/java/com/example/observer/camera/CameraController.kt b/app/src/main/java/com/example/observer/camera/CameraController.kt
new file mode 100644
index 0000000..c5ec446
--- /dev/null
+++ b/app/src/main/java/com/example/observer/camera/CameraController.kt
@@ -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"
+ }
+}
diff --git a/app/src/main/java/com/example/observer/camera/FrameAnalyzer.kt b/app/src/main/java/com/example/observer/camera/FrameAnalyzer.kt
new file mode 100644
index 0000000..75d9a15
--- /dev/null
+++ b/app/src/main/java/com/example/observer/camera/FrameAnalyzer.kt
@@ -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, rotation: Int, imageWidthPx: Int, imageHeightPx: Int, motionRegions: List) -> 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()
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/observer/camera/MotionDetector.kt b/app/src/main/java/com/example/observer/camera/MotionDetector.kt
new file mode 100644
index 0000000..e173899
--- /dev/null
+++ b/app/src/main/java/com/example/observer/camera/MotionDetector.kt
@@ -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 {
+ 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
+ }
+}
diff --git a/app/src/main/java/com/example/observer/data/SettingsRepository.kt b/app/src/main/java/com/example/observer/data/SettingsRepository.kt
new file mode 100644
index 0000000..9ae6eb0
--- /dev/null
+++ b/app/src/main/java/com/example/observer/data/SettingsRepository.kt
@@ -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 = 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")
+ }
+}
diff --git a/app/src/main/java/com/example/observer/detection/CoordinateMapper.kt b/app/src/main/java/com/example/observer/detection/CoordinateMapper.kt
new file mode 100644
index 0000000..dcd84ec
--- /dev/null
+++ b/app/src/main/java/com/example/observer/detection/CoordinateMapper.kt
@@ -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)
+}
diff --git a/app/src/main/java/com/example/observer/detection/DetectionResult.kt b/app/src/main/java/com/example/observer/detection/DetectionResult.kt
new file mode 100644
index 0000000..0d49bc3
--- /dev/null
+++ b/app/src/main/java/com/example/observer/detection/DetectionResult.kt
@@ -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"
+ }
+}
diff --git a/app/src/main/java/com/example/observer/detection/Detector.kt b/app/src/main/java/com/example/observer/detection/Detector.kt
new file mode 100644
index 0000000..4c4df00
--- /dev/null
+++ b/app/src/main/java/com/example/observer/detection/Detector.kt
@@ -0,0 +1,8 @@
+package com.example.observer.detection
+
+import android.graphics.Bitmap
+
+interface Detector {
+ /** 输入 RGBA 位图,输出归一化坐标检测结果(0..1) */
+ fun detect(bitmap: Bitmap): List
+}
diff --git a/app/src/main/java/com/example/observer/detection/MotionAggregator.kt b/app/src/main/java/com/example/observer/detection/MotionAggregator.kt
new file mode 100644
index 0000000..0ead024
--- /dev/null
+++ b/app/src/main/java/com/example/observer/detection/MotionAggregator.kt
@@ -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 {
+ 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(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()
+ 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 {
+ val bx = i % BLOCK_GRID
+ val by = i / BLOCK_GRID
+ val list = ArrayList(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
+ }
+}
diff --git a/app/src/main/java/com/example/observer/detection/MotionRegion.kt b/app/src/main/java/com/example/observer/detection/MotionRegion.kt
new file mode 100644
index 0000000..cb49cf9
--- /dev/null
+++ b/app/src/main/java/com/example/observer/detection/MotionRegion.kt
@@ -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
+}
diff --git a/app/src/main/java/com/example/observer/detection/Nms.kt b/app/src/main/java/com/example/observer/detection/Nms.kt
new file mode 100644
index 0000000..8aca430
--- /dev/null
+++ b/app/src/main/java/com/example/observer/detection/Nms.kt
@@ -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, iouThreshold: Float): List {
+ val sorted = boxes.sortedByDescending { it.score }
+ val kept = mutableListOf()
+ for (b in sorted) {
+ if (kept.none { iou(b, it) > iouThreshold }) kept += b
+ }
+ return kept
+}
diff --git a/app/src/main/java/com/example/observer/detection/TFLiteDetector.kt b/app/src/main/java/com/example/observer/detection/TFLiteDetector.kt
new file mode 100644
index 0000000..5f919ca
--- /dev/null
+++ b/app/src/main/java/com/example/observer/detection/TFLiteDetector.kt
@@ -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,
+) : 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 {
+ 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 {
+ val boxes = ArrayList(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() }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/observer/distance/DistanceEstimator.kt b/app/src/main/java/com/example/observer/distance/DistanceEstimator.kt
new file mode 100644
index 0000000..65dde6b
--- /dev/null
+++ b/app/src/main/java/com/example/observer/distance/DistanceEstimator.kt
@@ -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()
+
+ // 参考体型(米)
+ 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
+ }
+}
diff --git a/app/src/main/java/com/example/observer/overlay/DetectionOverlay.kt b/app/src/main/java/com/example/observer/overlay/DetectionOverlay.kt
new file mode 100644
index 0000000..643cc5d
--- /dev/null
+++ b/app/src/main/java/com/example/observer/overlay/DetectionOverlay.kt
@@ -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,
+ 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,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/observer/reminder/Reminder.kt b/app/src/main/java/com/example/observer/reminder/Reminder.kt
new file mode 100644
index 0000000..47cdf3a
--- /dev/null
+++ b/app/src/main/java/com/example/observer/reminder/Reminder.kt
@@ -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()
+ }
+}
diff --git a/app/src/main/java/com/example/observer/ui/ObserverApp.kt b/app/src/main/java/com/example/observer/ui/ObserverApp.kt
new file mode 100644
index 0000000..54bbc2b
--- /dev/null
+++ b/app/src/main/java/com/example/observer/ui/ObserverApp.kt
@@ -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 })
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/observer/ui/camera/CameraScreen.kt b/app/src/main/java/com/example/observer/ui/camera/CameraScreen.kt
new file mode 100644
index 0000000..74102ec
--- /dev/null
+++ b/app/src/main/java/com/example/observer/ui/camera/CameraScreen.kt
@@ -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("授权相机") }
+ }
+}
diff --git a/app/src/main/java/com/example/observer/ui/camera/CameraViewModel.kt b/app/src/main/java/com/example/observer/ui/camera/CameraViewModel.kt
new file mode 100644
index 0000000..34c2833
--- /dev/null
+++ b/app/src/main/java/com/example/observer/ui/camera/CameraViewModel.kt
@@ -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 = 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 = _state.asStateFlow()
+
+ private val _analyzerIntervalMs = MutableStateFlow(DetectMode.CONTINUOUS.intervalMs)
+ val analyzerIntervalMs: StateFlow = _analyzerIntervalMs.asStateFlow()
+
+ @Volatile
+ private var showDistance = true
+
+ @Volatile
+ private var confThreshold = 0.40f
+
+ @Volatile
+ private var cameraId: String? = null
+
+ private val tracks = LinkedHashMap()
+
+ 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,
+ rotation: Int,
+ imageWidthPx: Int,
+ imageHeightPx: Int,
+ motionRegions: List,
+ ) {
+ val now = SystemClock.elapsedRealtime()
+ val byKey = HashMap(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): 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 create(modelClass: Class): 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}")
+ }
+}
diff --git a/app/src/main/java/com/example/observer/ui/settings/SettingsScreen.kt b/app/src/main/java/com/example/observer/ui/settings/SettingsScreen.kt
new file mode 100644
index 0000000..8235592
--- /dev/null
+++ b/app/src/main/java/com/example/observer/ui/settings/SettingsScreen.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/com/example/observer/ui/theme/Theme.kt b/app/src/main/java/com/example/observer/ui/theme/Theme.kt
new file mode 100644
index 0000000..97fc247
--- /dev/null
+++ b/app/src/main/java/com/example/observer/ui/theme/Theme.kt
@@ -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)
+}
diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml
new file mode 100644
index 0000000..87f295d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..0b6f2d0
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ 野视
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..2a12f06
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,7 @@
+
+
+
diff --git a/app/src/test/java/com/example/observer/detection/CoordinateMapperTest.kt b/app/src/test/java/com/example/observer/detection/CoordinateMapperTest.kt
new file mode 100644
index 0000000..2773bf3
--- /dev/null
+++ b/app/src/test/java/com/example/observer/detection/CoordinateMapperTest.kt
@@ -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
+ }
+}
diff --git a/app/src/test/java/com/example/observer/detection/MotionAggregatorTest.kt b/app/src/test/java/com/example/observer/detection/MotionAggregatorTest.kt
new file mode 100644
index 0000000..c8f3427
--- /dev/null
+++ b/app/src/test/java/com/example/observer/detection/MotionAggregatorTest.kt
@@ -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))
+ }
+}
diff --git a/app/src/test/java/com/example/observer/detection/NmsTest.kt b/app/src/test/java/com/example/observer/detection/NmsTest.kt
new file mode 100644
index 0000000..f6ef17f
--- /dev/null
+++ b/app/src/test/java/com/example/observer/detection/NmsTest.kt
@@ -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)
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..9deb573
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,5 @@
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+}
diff --git a/docs/01-技术方案.md b/docs/01-技术方案.md
new file mode 100644
index 0000000..aa0fc28
--- /dev/null
+++ b/docs/01-技术方案.md
@@ -0,0 +1,318 @@
+# Observer(野视)· 野生动物实时识别 Android App — 技术方案
+
+| 项目 | 内容 |
+| --- | --- |
+| 文档版本 | v1.1 |
+| 编写日期 | 2026-08-17 |
+| 状态 | 初稿(v1.1:确认功能范围为"仅实时识别",移除拍照留存) |
+| 适用产品 | 纯 Android 原生 App |
+
+---
+
+## 1. 项目概述
+
+### 1.1 项目背景
+
+野生动物观察爱好者在户外需要一款便携工具:打开手机相机,即可从实时画面中识别野鸡,通过检测框与提醒辅助快速发现。
+
+### 1.2 项目目标
+
+- 端侧实时目标检测,**全程离线可用**,不依赖网络;
+- 野鸡实时框选识别,展示类别、置信度与**大致距离**;
+- 检测到目标时通过震动 / 声音提醒用户,辅助快速发现;
+- 纯 Android 原生 App(Kotlin),兼容 Android 7.0+ 主流机型,中端机流畅运行;
+- **不做拍照、记录、统计等留存功能**,专注实时识别这一核心体验。
+
+### 1.3 名词术语
+
+| 术语 | 说明 |
+| --- | --- |
+| ImageAnalysis | CameraX 的帧分析用例,用于逐帧回调图像数据 |
+| TFLite | TensorFlow Lite,Google 端侧推理框架 |
+| YOLO | You Only Look Once,单阶段目标检测算法 |
+| NMS | Non-Maximum Suppression,非极大值抑制 |
+| GPU Delegate | TFLite 的 GPU 加速委托,将算子下发 GPU 执行 |
+| mAP | mean Average Precision,目标检测平均精度指标 |
+| IoU | Intersection over Union,交并比 |
+
+---
+
+## 2. 需求概述
+
+### 2.1 目标用户
+
+| 用户群 | 典型诉求 |
+| --- | --- |
+| 野生动物观察爱好者 | 户外实时识别画面中的野鸡,不惊扰、近距离观察 |
+| 户外徒步 / 摄影人群 | 快速发现野鸡,辅助取景构图 |
+| 自然教育、科普工作者 | 物种识别辅助教学 |
+
+### 2.2 核心场景
+
+| 编号 | 场景 | 描述 |
+| --- | --- | --- |
+| S1 | 野外实时识别 | 徒步 / 观鸟时打开相机,实时框选画面中的野鸡,显示类别、置信度与大致距离 |
+| S2 | 快速扫视寻找 | 移动取景快速扫视,检测到野鸡立即震动 / 声音提醒,无需停留操作 |
+
+### 2.3 功能需求摘要
+
+实时识别(P0)、识别结果叠加展示(P0)、检测提醒(P1)、生境区域预警(P1)、设置(P1)、合规提示(P0)、低光增强(P2)。详见《项目功能文档》。
+
+### 2.4 非功能需求摘要
+
+| 指标 | 目标 |
+| --- | --- |
+| 识别延迟 | 中端机单帧 ≤ 80ms |
+| 预览流畅度 | 连续模式 2~3 帧检测一次,预览不卡顿 |
+| 耗电 | 连续使用 1 小时耗电 ≤ 15% |
+| 兼容性 | Android 7.0+(minSdk 24),覆盖主流国产机与三星 |
+| 稳定性 | 崩溃率 ≤ 0.5%,启动成功率 ≥ 99% |
+
+---
+
+## 3. 总体架构
+
+### 3.1 架构图
+
+```mermaid
+graph TD
+ subgraph UI层["UI 层 · Jetpack Compose"]
+ MainScreen["相机主界面
预览 + 检测叠加层"]
+ SettingsScreen["设置界面"]
+ end
+
+ subgraph ViewModel层["ViewModel 层"]
+ CameraViewModel["CameraViewModel"]
+ SettingsViewModel["SettingsViewModel"]
+ end
+
+ subgraph 领域层["领域层"]
+ Detector接口["Detector 接口"]
+ end
+
+ subgraph 基础设施层["基础设施层"]
+ CameraX["CameraX
Preview / ImageAnalysis"]
+ TFLite["TFLite 推理引擎
YOLOv8n 模型 + GPU Delegate"]
+ DataStore["DataStore 设置存储"]
+ end
+
+ UI层 --> ViewModel层
+ ViewModel层 --> 领域层
+ 领域层 --> 基础设施层
+```
+
+### 3.2 分层说明
+
+| 层 | 职责 |
+| --- | --- |
+| UI 层 | Compose 页面渲染、检测叠加层绘制、交互 |
+| ViewModel 层 | 页面状态管理、识别结果分发、提醒触发 |
+| 领域层 | 检测器抽象接口 |
+| 基础设施层 | CameraX、TFLite、DataStore 等能力实现 |
+
+### 3.3 核心数据流(识别链路)
+
+```mermaid
+flowchart LR
+ A["CameraX 相机帧"] --> B["ImageAnalysis 帧分析"]
+ B --> C["预处理
缩放 320×320 / 归一化 / 旋转校正"]
+ C --> D["TFLite 推理
YOLOv8n"]
+ D --> E["后处理
解码 / NMS / 阈值过滤"]
+ E --> F["叠加层渲染
边框 + 类别 + 置信度"]
+ F --> G["UI 展示"]
+ E --> H["结果提醒
震动 / 提示音"]
+```
+
+### 3.4 部署形态
+
+- **本地优先**:识别全部离线完成,运行期无网络请求;
+- **云端(V2 可选)**:仅用于模型版本更新下发。
+
+---
+
+## 4. 技术选型
+
+| 类别 | 选型 | 理由 |
+| --- | --- | --- |
+| 开发语言 | Kotlin | Android 官方推荐,协程生态成熟 |
+| UI 框架 | Jetpack Compose(Material 3) | 声明式 UI,开发效率高,叠加层绘制灵活 |
+| 相机 | CameraX(Preview / ImageAnalysis) | Jetpack 官方库,生命周期安全,机型兼容性最佳 |
+| 目标检测模型 | YOLOv8n(自定义 4 类训练) | 精度/速度均衡,端侧部署方案成熟 |
+| 推理框架 | TensorFlow Lite 2.16+(GPU Delegate) | 官方支持 GPU 加速;备选 NCNN / MNN |
+| 配置存储 | DataStore Preferences | 阈值、开关等设置 |
+| 异步 | Kotlin Coroutines + Flow | 主线程安全,生命周期感知 |
+| 构建 | Gradle(Kotlin DSL)+ AGP 8.x | 现代构建配置 |
+
+---
+
+## 5. 核心功能技术方案
+
+### 5.1 实时识别管线
+
+- CameraX 组合:`Preview`(取景)+ `ImageAnalysis`(识别);
+- `ImageAnalysis` 使用 `STRATEGY_KEEP_ONLY_LATEST` 背压策略,保证不积压帧;
+- 输出格式使用 `OUTPUT_IMAGE_FORMAT_RGBA_8888`(CameraX 1.3+),免去 YUV→RGB 手动转换;
+- **帧节流**:连续模式每 2~3 帧检测一次;标准模式 300ms 一次;省电模式每秒一次(依据设置);
+- 推理在独立检测线程执行,单例互斥锁防止重叠推理;分析线程不阻塞主线程。
+
+### 5.2 检测叠加层
+
+- Compose Canvas 绘制检测框(矩形 + 类别标签 + 置信度),颜色按类别区分;
+- 检测框内同时标注**大致距离**(如"约 25m"),随检测框实时更新;
+- 距离标注与检测框同生共灭,展示逻辑一致(≥ 0.5s 出现、消失 2s 后移除);
+- 坐标映射链路:模型归一化坐标 → 旋转校正(sensor rotation)→ 预览视图坐标(含 FIT_CENTER 裁剪偏移修正);
+- 目标保持 ≥ 0.5s 才展示,避免单帧误检闪烁。
+
+### 5.3 距离标注(单目估计)
+
+- 采用单目针孔模型:`距离 ≈ 焦距px × 物种参考体型 / 检测框像素高度`;
+- 焦距 px 由相机内参换算(`LENS_INFO_AVAILABLE_FOCAL_LENGTHS` × `SENSOR_INFO_PHYSICAL_SIZE`),个别机型回退用视场角推算;
+- 物种参考体型内置表(见 6.1),每类一个平均体型值;
+- 展示为"约 X m";5~50m 范围误差预期 ≤ ±30%;
+- 纯算术运算,无额外模型与算力开销;V2 可引入地面平面法(设备俯仰角 + 相机高度)提升精度。
+
+### 5.4 检测提醒
+
+- 检测到目标时触发震动 / 提示音,用户可开关;
+- 防重复打扰:10s 内同类目标只提醒一次;
+- 目标消失后 2s 内不闪断显示,避免频繁提醒。
+
+### 5.5 低光增强(P2)
+
+- 简单直方图均衡 / 亮度增益提升低光帧可见度;
+- V2 可引入夜视增强网络思路。
+
+---
+
+## 6. AI 模型方案(核心)
+
+### 6.1 目标类别定义
+
+| 类别 ID | 英文标签 | 中文名 | 涵盖范围 | 参考体型(距离估计用) |
+| --- | --- | --- | --- | --- |
+| 0 | pheasant | 野鸡 | 环颈雉等雉类 | ≈ 0.45m(身高) |
+
+### 6.2 模型选型对比
+
+| 模型 | 输入尺寸 | 中端机单帧耗时(GPU) | 精度 | 说明 |
+| --- | --- | --- | --- | --- |
+| **YOLOv8n(推荐)** | 320~640 | 约 30~80ms | 高 | 支持自定义类别训练,精度/速度平衡 |
+| EfficientDet-Lite0/2 | 320 | 约 20~50ms | 中 | 仅 COCO 预置类别 |
+| YOLOv5s | 640 | 约 80~150ms | 高 | 体积与耗电偏大 |
+| SSD MobileNetV2 | 300 | 约 15~30ms | 低 | 小目标与远距离效果差 |
+
+**结论**:选用 **YOLOv8n**,使用预训练权重迁移学习自定义训练,导出 TFLite 端侧部署。
+
+### 6.3 数据集方案
+
+| 项 | 方案 |
+| --- | --- |
+| 数据量 | 1000~2000 张(首版可 500+ 起步,滚动补充) |
+| 多样性 | 覆盖不同季节、晨昏/正午/逆光、远近距离、姿态、遮挡、背景(草丛/农田/林地/雪地) |
+| 标注 | Roboflow 或 labelImg,YOLO 格式(class, cx, cy, w, h) |
+| 数据增强 | Mosaic、MixUp、HSV 扰动、随机翻转、随机缩放裁剪 |
+| 数据划分 | train 80% / val 10% / test 10% |
+| 负样本 | 补充无目标场景图,控制误检 |
+
+### 6.4 训练方案
+
+- 框架:ultralytics YOLOv8,预训练权重 `yolov8n.pt` 迁移学习;
+- 超参:imgsz=640(训练)、epochs 100~200(早停)、batch 16~32(视 GPU 而定);
+- 评估指标:
+ - mAP@0.5 ≥ **0.85**(达标线 0.80);
+ - mAP@0.5:0.95 ≥ **0.55**;
+ - 负样本误检率 ≤ **2%**;
+ - 远距离小目标(高度 ≤ 20px)召回率 ≥ **60%**。
+
+### 6.5 模型转换与量化
+
+- 导出命令:`yolo export model=best.pt format=tflite imgsz=320`;
+- 量化策略:
+ - 优先 **fp16**(配合 GPU Delegate,精度损失小);
+ - 低端机 / CPU 场景使用 **int8** 量化(体积更小、CPU 更快);
+- 输入尺寸:320×320(连续检测)/ 416×416(远距离模式,V2)。
+
+### 6.6 推理优化
+
+- GPU Delegate 优先,初始化失败自动回退 CPU(NNAPI 可选);
+- 推理线程 4 线程,首次推理前执行预热(dummy run);
+- 输入输出 ByteBuffer / Bitmap 复用,避免热路径频繁分配。
+
+### 6.7 精度与速度目标
+
+| 指标 | 目标 |
+| --- | --- |
+| 检测阈值(默认) | confidence ≥ 0.40,IoU-NMS = 0.45 |
+| 单帧推理延迟 | ≤ 80ms(中端机,320 输入,GPU) |
+| 模型体积 | fp16 ≤ 8MB,int8 ≤ 4MB |
+| 识别类别数 | 1 类(野鸡) |
+| 距离标注 | "约 X m",5~50m 误差 ≤ ±30%,计算开销可忽略 |
+
+---
+
+## 7. 性能指标与优化
+
+| 指标 | 目标值 | 主要优化手段 |
+| --- | --- | --- |
+| 启动到相机预览 | ≤ 1.5s | 启动即初始化 CameraProvider,懒加载非核心模块 |
+| 单帧检测延迟 | ≤ 80ms | GPU Delegate、320 输入、线程复用 |
+| 预览帧率 | ≥ 25fps | 帧节流、KEEP_ONLY_LATEST、避免主线程工作 |
+| 内存峰值 | ≤ 200MB | Bitmap/ByteBuffer 复用,无大对象常驻 |
+| 连续 1 小时耗电 | ≤ 15% | 检测帧节流、省电模式、后台自动释放相机 |
+| App 体积 | ≤ 40MB | ABI 拆分、模型量化、R8 混淆 |
+
+---
+
+## 8. 数据与隐私
+
+- 无账号、无埋点、无广告 SDK,**不采集任何用户数据**;
+- 不保存照片、不记录位置,运行期无网络请求;
+- 模型与标签随 APK 打包,识别数据仅存在于内存中,随进程结束自动释放。
+
+---
+
+## 9. 安全与合规
+
+- 产品定位为**野生动物观察、识别工具**,不提供任何猎捕、诱捕、伤害野生动物的功能或指导;
+- 遵守《中华人民共和国野生动物保护法》《陆生野生动物保护实施条例》等法律法规;
+- 野鸡(雉类)属"三有"保护动物,猎捕须依法取得许可,App 不鼓励、不协助非法猎捕;
+- App 内置观察伦理提示:保持安全距离、不惊扰动物、遵守保护区管理规定(首次启动展示,功能文档 F07)。
+
+---
+
+## 10. 开发计划(里程碑)
+
+| 阶段 | 周期 | 交付内容 |
+| --- | --- | --- |
+| M0 准备 | 1 周 | 需求确认、数据集启动采集、Android 工程脚手架、模型基线 |
+| M1 MVP | 3~4 周 | 相机预览 + 实时检测 + 叠加层 + 检测提醒 + 设置 + 合规提示 |
+| M2 打磨发布 | 1~2 周 | 性能优化、真机矩阵测试、混淆加固、上架准备 |
+
+总计约 **5~7 周**(不含数据采集并行时间)。
+
+---
+
+## 11. 风险与应对
+
+| 风险 | 影响 | 应对 |
+| --- | --- | --- |
+| 训练数据不足 / 类别相近 | 精度低、误检 | 持续滚动采集数据;增加难例挖掘;灰度发布迭代模型 |
+| 小目标(远处动物) | 漏检 | 提高输入分辨率;帧节流换取算力;提示用户靠近/变焦;V2 引入 SAHI/tiling 或专用小目标模型 |
+| 低光 / 夜间场景 | 漏检 | 低光增强预处理;V2 夜视模式 |
+| 单目距离估计精度有限 | 距离显示不准 | 标注为"约"并明示误差预期;体型表持续校准;V2 地面平面法 + 水下折射修正 |
+| 生境区域误报偏多 | 提醒频繁、体验差 | 独立低阈值 + 区分提醒方式 + 防重复机制;生境阈值可调;模型迭代降低误报 |
+| 中低端机型性能不足 | 帧率低、发热 | 动态分辨率、降频检测、GPU 回退策略、省电模式 |
+| CameraX 个别机型异常 | 黑屏/闪退 | 机型兼容测试矩阵、崩溃监控、失败回退(重试/默认配置) |
+| 合规风险(被用于非法猎捕) | 法律风险 | 产品定位为观察工具、内置合规提示、不提供猎捕辅助功能 |
+
+---
+
+## 12. 验收标准
+
+1. 野鸡测试集 mAP@0.5 ≥ 0.85,负样本误检率 ≤ 2%;
+2. 中端机(如骁龙 7 系)单帧检测 ≤ 80ms,预览流畅无卡顿;
+3. 连续使用 1 小时耗电 ≤ 15%,无异常发热;
+4. 主流机型(小米 / 华为 / OPPO / vivo / 三星,各 ≥ 2 台)启动成功率 ≥ 99%,崩溃率 ≤ 0.5%;
+5. 飞行模式下实时识别全流程(预览、检测、提醒、设置)可用;
+6. 检测框正确显示类别、置信度与距离;5~50m 范围距离误差 ≤ ±30%;
+7. 通过应用商店合规审核(隐私政策、权限声明完整)。
diff --git a/docs/02-项目功能文档.md b/docs/02-项目功能文档.md
new file mode 100644
index 0000000..574f117
--- /dev/null
+++ b/docs/02-项目功能文档.md
@@ -0,0 +1,193 @@
+# Observer(野视)· 项目功能文档
+
+| 项目 | 内容 |
+| --- | --- |
+| 文档版本 | v1.1 |
+| 编写日期 | 2026-08-17 |
+| 状态 | 初稿(v1.1:确认功能范围为"仅实时识别",移除拍照留存) |
+| 配套文档 | 《01-技术方案》《03-技术实现文档》 |
+
+---
+
+## 1. 产品概述
+
+### 1.1 产品定位
+
+一款**纯 Android 原生**的野生动物实时识别 App。用户打开相机,即可从实时画面中识别野鸡,通过彩色检测框(含类别、置信度、距离标注)与震动 / 声音提醒辅助快速发现。**无拍照、无记录、无统计**,专注实时识别这一核心体验,**全程离线可用**。
+
+### 1.2 目标用户
+
+- 野生动物观察爱好者(观鸟 / 观兽);
+- 户外徒步、摄影人群;
+- 自然教育、科普工作者。
+
+### 1.3 价值主张
+
+- **即时发现**:打开相机即识别,检测到目标立即提醒,免去翻图鉴、猜物种;
+- **零门槛**:不依赖网络,不依赖外设,一部手机即可;
+- **纯净体验**:无留存功能、无账号、无广告,即开即用。
+
+---
+
+## 2. 功能架构(功能树)
+
+```
+Observer
+├── 实时识别
+│ ├── 相机实时预览(F01)
+│ ├── 目标实时检测(F02)
+│ ├── 识别结果展示与距离标注(F03)
+│ ├── 检测提醒(F04)
+│ └── 低光增强(F06,P2)
+├── 设置
+│ ├── 识别参数(F05)
+│ └── 提醒设置(F04 子项)
+└── 合规提示(F07)
+```
+
+---
+
+## 3. 功能需求清单
+
+优先级定义:**P0**=必须,**P1**=V1.0,**P2**=后续版本。
+
+| 编号 | 功能 | 功能描述 | 优先级 |
+| --- | --- | --- | --- |
+| F01 | 相机实时预览 | 全屏取景,默认后置,支持自动对焦、双指缩放、点击对焦、前后摄切换 | P0 |
+| F02 | 目标实时检测 | 对野鸡实时检测框选 | P0 |
+| F03 | 识别结果展示与距离标注 | 检测框 + 类别名 + 置信度百分比 + 大致距离(约 X m);四类目标使用不同颜色边框 | P0 |
+| F04 | 检测提醒 | 检测到目标时震动 / 提示音;支持开关与 10s 防重复 | P1 |
+| F05 | 设置 | 置信度阈值(0.2~0.7)、检测频率(连续 / 标准 / 省电)、提醒开关、低光增强开关 | P1 |
+| F06 | 低光增强 | 低光环境下自动亮度 / 对比度增强,提高识别率 | P2 |
+| F07 | 合规提示 | 首次启动展示《观察伦理与法律提示》,需用户确认 | P0 |
+| F08 | 生境区域预警 | 画面中未检测到目标时,识别草丛 / 灌木 / 水面等疑似生境区域,以黄色虚线框展示(含距离标注)并触发预警(独立阈值与提醒方式) | P1 |
+
+---
+
+## 4. 核心业务流程
+
+### 4.1 首次启动与授权
+
+```
+启动 App → 合规提示页(F07,用户确认)
+ → 请求相机权限
+ ├─ 授权 → 进入相机主界面
+ └─ 拒绝 → 引导页说明用途,可重新授权
+```
+
+### 4.2 实时识别
+
+```
+相机主界面(默认后置)
+→ 画面持续检测(连续 / 标准 / 省电频率)
+→ 检测到目标:显示彩色检测框 + 类别 + 置信度 + 距离
+ ├─ 触发提醒(震动 / 声音,10s 防重复)
+ └─ 目标停留 ≥ 0.5s 保持显示,消失后 2s 内不闪断
+→ 未检测到目标时:识别疑似生境区域(草丛 / 灌木 / 水面)
+ ├─ 黄色虚线框展示,标注"疑似区域"与距离
+ └─ 触发预警(提醒方式与动物检测区分)
+→ 用户可点击检测框查看识别详情(物种、置信度、时间)
+```
+
+---
+
+## 5. 界面设计
+
+### 5.1 界面总览
+
+仅两个界面:**相机主界面**(默认全屏展示)+ **设置页**(右上角齿轮入口)。
+
+### 5.2 相机主界面(核心界面)
+
+```
+┌─────────────────────────────────┐
+│ [低光] [频率] [前/后摄] │ ← 顶部工具
+│ 相机预览画面(全屏) │
+│ ┌─────────────────────────────┐ │
+│ │ ┌───────┐ │ │
+│ │ │ 野鸡 86% │ │ │ ← 检测框(按类别着色)
+│ │ │ 约 25m │ │ │
+│ │ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ │ │ ← 疑似生境区域(黄色虚线,预警)
+│ │ 疑似区域 · 约 20m │ │
+│ │ └───────┘ │ │
+│ └─────────────────────────────┘ │
+│ [提醒开关] [设置] │ ← 底部操作
+└─────────────────────────────────┘
+```
+
+交互说明:
+
+- 顶部:检测频率切换(连续 / 标准 / 省电)、低光增强开关(P2)、前后摄像头切换;
+- 中部:全屏预览,检测框 + 类别 + 置信度实时叠加;
+- 底部:提醒开关(震动 / 声音)、设置入口;
+- 支持双指缩放、点击对焦;
+- 检测到目标时边框高亮 + 震动 / 声音提醒(10s 防重复);
+- 检测框内显示类别、置信度与大致距离(约 X m),随目标实时更新;
+- 未检测到目标时,疑似生境区域以黄色虚线框展示(含距离标注)并预警(提醒方式与动物检测区分)。
+
+### 5.3 识别结果卡片(点击检测框弹出)
+
+| 元素 | 说明 |
+| --- | --- |
+| 物种名称 | 中文名 + 英文标签(如:野鸡 pheasant) |
+| 置信度 | 百分比进度条 |
+| 时间 | 检测时刻 |
+| 距离 | 约 25m(单目估算,误差 ±30%) |
+
+仅展示信息,无保存操作。
+
+疑似生境区域(黄色虚线框)点击后展示:区域类别、置信度、距离(约 X m,按参考植被高度估算,误差较大)。
+
+### 5.4 设置页
+
+| 分组 | 设置项 | 默认值 |
+| --- | --- | --- |
+| 识别 | 置信度阈值 | 0.40 |
+| 识别 | 检测频率 | 连续 |
+| 识别 | 低光增强 | 关(P2) |
+| 识别 | 距离标注 | 开 |
+| 识别 | 生境区域阈值 | 0.35 |
+| 提醒 | 震动提醒 / 提示音 / 防重复时长 | 开 / 开 / 10s |
+| 提醒 | 生境区域预警 | 开 |
+| 关于 | 版本信息 / 隐私说明 | — |
+
+---
+
+## 6. 权限设计
+
+| 权限 | 用途 | 时机 | 备注 |
+| --- | --- | --- | --- |
+| CAMERA | 实时预览与识别 | 首次启动 | 必须 |
+
+原则:**最小权限、按需申请**。仅申请相机权限;拒绝后提供说明引导页(可跳转系统设置重新授权)。不申请定位、存储等任何其他权限。
+
+---
+
+## 7. 非功能需求
+
+| 类别 | 要求 |
+| --- | --- |
+| 兼容性 | Android 7.0+(minSdk 24),竖屏为主,支持暗色模式 |
+| 性能 | 见《技术方案》第 7 章(延迟 ≤ 80ms、启动 ≤ 1.5s、内存 ≤ 200MB) |
+| 离线 | 飞行模式下实时识别全流程完整可用 |
+| 稳定性 | 崩溃率 ≤ 0.5%,启动成功率 ≥ 99%,异常自动降级(GPU 回退 CPU) |
+| 耗电 | 连续使用 1 小时 ≤ 15%;省电模式 ≤ 8% |
+| 隐私 | 无广告 SDK、无埋点、不保存任何数据,识别数据仅存内存 |
+| 无障碍 | 关键操作支持 TalkBack 描述;检测结果支持语音播报(V2) |
+
+---
+
+## 8. 版本规划
+
+| 版本 | 范围 | 说明 |
+| --- | --- | --- |
+| MVP / V1.0 | F01~F05、F07、F08 | 相机 + 实时识别 + 生境预警 + 提醒 + 设置,即完整核心体验 |
+| V2.0 | +F06 及增强 | 低光增强、更多物种、夜视模式、模型远程更新、语音播报、距离精度增强(地面平面法) |
+
+---
+
+## 9. 合规与安全说明
+
+- App 定位为**野生动物观察、识别工具**,不提供猎捕、诱捕、伤害野生动物的功能或指导;
+- 首次启动展示合规提示:遵守《中华人民共和国野生动物保护法》,野鸡属"三有"保护动物,猎捕须依法许可;观察时保持距离、不惊扰动物、遵守保护区规定;
+- 上架需提供隐私政策,声明仅使用相机权限、不采集与存储任何用户数据。
diff --git a/docs/03-技术实现文档.md b/docs/03-技术实现文档.md
new file mode 100644
index 0000000..f81ceab
--- /dev/null
+++ b/docs/03-技术实现文档.md
@@ -0,0 +1,512 @@
+# Observer(野视)· 技术实现文档
+
+| 项目 | 内容 |
+| --- | --- |
+| 文档版本 | v1.1 |
+| 编写日期 | 2026-08-17 |
+| 状态 | 初稿(v1.1:确认功能范围为"仅实时识别",移除拍照、记录、相册识别、定位相关实现) |
+| 配套文档 | 《01-技术方案》《02-项目功能文档》 |
+
+---
+
+## 1. 项目结构
+
+```
+app/
+├── src/main/
+│ ├── java/com/example/observer/
+│ │ ├── MainActivity.kt // 单 Activity 入口
+│ │ ├── camera/
+│ │ │ ├── CameraController.kt // CameraX 生命周期绑定与用例组合
+│ │ │ └── FrameAnalyzer.kt // ImageAnalysis 帧分析器(节流 + 调度)
+│ │ ├── detection/
+│ │ │ ├── Detector.kt // 检测器抽象接口
+│ │ │ ├── TFLiteDetector.kt // TFLite 实现(YOLOv8n)
+│ │ │ ├── DetectionResult.kt // 检测结果数据类
+│ │ │ ├── Nms.kt // NMS 后处理
+│ │ │ └── CoordinateMapper.kt // 模型坐标 → 视图坐标映射
+│ │ ├── distance/
+│ │ │ └── DistanceEstimator.kt // 单目距离估计(针孔模型)
+│ │ ├── overlay/
+│ │ │ └── DetectionOverlay.kt // Compose 叠加层(Canvas 绘制检测框)
+│ │ ├── reminder/
+│ │ │ └── Reminder.kt // 震动 / 提示音提醒(含防重复)
+│ │ ├── ui/
+│ │ │ ├── camera/ CameraScreen.kt / CameraViewModel.kt
+│ │ │ └── settings/ SettingsScreen.kt / SettingsViewModel.kt
+│ │ └── util/
+│ │ └── BitmapUtils.kt // 缩放 / 旋转 / 复用
+│ ├── assets/
+│ │ ├── model.tflite // YOLOv8n 四类模型
+│ │ └── labels.txt // 类别标签(按 ID 顺序)
+│ └── res/
+└── build.gradle.kts
+```
+
+---
+
+## 2. 技术栈与版本
+
+| 组件 | 版本(以最新稳定为准) | 用途 |
+| --- | --- | --- |
+| Kotlin | 2.x | 开发语言 |
+| AGP | 8.x | Android Gradle 插件 |
+| Jetpack Compose | BOM 2024.x+(Material 3) | UI |
+| CameraX | 1.4.x | Preview / ImageAnalysis |
+| TensorFlow Lite | 2.16.x | 端侧推理 |
+| tensorflow-lite-gpu | 2.16.x | GPU 加速 |
+| DataStore | 1.1.x | 设置存储 |
+
+依赖(build.gradle.kts 关键片段):
+
+```kotlin
+dependencies {
+ implementation("androidx.camera:camera-core:1.4.1")
+ implementation("androidx.camera:camera-camera2:1.4.1")
+ implementation("androidx.camera:camera-lifecycle:1.4.1")
+ implementation("androidx.camera:camera-view:1.4.1")
+
+ implementation("org.tensorflow:tensorflow-lite:2.16.1")
+ implementation("org.tensorflow:tensorflow-lite-gpu:2.16.1")
+
+ implementation("androidx.datastore:datastore-preferences:1.1.1")
+}
+```
+
+---
+
+## 3. 模块设计
+
+### 3.1 camera 模块 — CameraController.kt
+
+职责:创建并绑定 CameraX 用例(Preview + ImageAnalysis),统一生命周期。
+
+```kotlin
+class CameraController(
+ private val lifecycleOwner: LifecycleOwner,
+ private val analysisExecutor: Executor,
+) {
+ private lateinit var cameraProvider: ProcessCameraProvider
+ private lateinit var imageAnalysis: ImageAnalysis
+
+ fun start(previewView: PreviewView, analyzer: ImageAnalysis.Analyzer) {
+ val future = ProcessCameraProvider.getInstance(previewView.context)
+ future.addListener({
+ cameraProvider = future.get()
+
+ val preview = Preview.Builder().build().also {
+ it.surfaceProvider = previewView.surfaceProvider
+ }
+
+ imageAnalysis = ImageAnalysis.Builder()
+ .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
+ .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888)
+ .build()
+ imageAnalysis.setAnalyzer(analysisExecutor, analyzer)
+
+ cameraProvider.bindToLifecycle(
+ lifecycleOwner,
+ CameraSelector.DEFAULT_BACK_CAMERA,
+ preview, imageAnalysis,
+ )
+ }, ContextCompat.getMainExecutor(previewView.context))
+ }
+}
+```
+
+要点:
+
+- `OUTPUT_IMAGE_FORMAT_RGBA_8888`(CameraX 1.3+)直接得到 RGBA 图像,免 YUV 转换;
+- `STRATEGY_KEEP_ONLY_LATEST`:分析器忙时丢弃旧帧,不积压;
+- 相机权限检查与请求在进入本模块前完成;
+- 前后摄切换:重新以对应 `CameraSelector` 执行 `bindToLifecycle`(解绑旧用例)。
+
+### 3.2 camera 模块 — FrameAnalyzer.kt
+
+职责:帧节流、旋转获取、调度检测、结果回调。
+
+```kotlin
+class FrameAnalyzer(
+ private val detector: Detector,
+ private val onResult: (List, Int) -> Unit, // 结果 + 旋转角
+) : ImageAnalysis.Analyzer {
+
+ private var lastDetectMs = 0L
+
+ override fun analyze(imageProxy: ImageProxy) {
+ val now = SystemClock.elapsedRealtime()
+ val interval = frameIntervalMs() // 依据设置:连续/标准/省电
+ if (now - lastDetectMs < interval) {
+ imageProxy.close(); return
+ }
+ lastDetectMs = now
+
+ val bitmap = imageProxy.toBitmap() // RGBA_8888 直接转 Bitmap
+ val rotation = imageProxy.imageInfo.rotationDegrees
+ try {
+ val results = detector.detect(bitmap)
+ onResult(results, rotation)
+ } finally {
+ imageProxy.close() // 必须关闭,否则阻塞流
+ }
+ }
+}
+```
+
+### 3.3 detection 模块 — Detector 接口
+
+```kotlin
+interface Detector {
+ /** 输入 RGBA 位图,输出归一化坐标检测结果(0..1) */
+ fun detect(bitmap: Bitmap): List
+}
+```
+
+### 3.4 detection 模块 — TFLiteDetector.kt
+
+```kotlin
+class TFLiteDetector(
+ context: Context,
+ private val inputSize: Int = 320,
+ private val confThreshold: Float = 0.40f, // 动物类阈值
+ private val habitatThreshold: Float = 0.35f, // 生境区域阈值
+ private val iouThreshold: Float = 0.45f,
+ gpuEnabled: Boolean = true,
+) : Detector {
+
+ private val labels: List =
+ context.assets.open("labels.txt").bufferedReader().readLines()
+
+ private val interpreter: Interpreter = Interpreter(
+ loadModelFile(context, "model.tflite"),
+ Interpreter.Options().apply {
+ setNumThreads(4)
+ if (gpuEnabled) {
+ try { addDelegate(GpuDelegate()) } catch (_: Exception) { /* 回退 CPU */ }
+ }
+ },
+ )
+
+ private val inputBuffer: ByteBuffer = ByteBuffer.allocateDirect(
+ 1 * inputSize * inputSize * 3 * 4 // float32
+ ).order(ByteOrder.nativeOrder())
+
+ private val outputBuffer: ByteBuffer = ByteBuffer.allocateDirect(
+ OUTPUT_ELEMENTS * 4
+ ).order(ByteOrder.nativeOrder())
+
+ override fun detect(bitmap: Bitmap): List {
+ preprocess(bitmap, inputBuffer) // 缩放 + RGB 归一化 → 输入缓冲
+ interpreter.run(inputBuffer, outputBuffer) // 单张推理
+ return postprocess(outputBuffer, bitmap.width, bitmap.height)
+ }
+}
+```
+
+输入 / 输出规格(YOLOv8n 四类,320 输入):
+
+- 输入:`[1, 320, 320, 3]`,RGB,float32 归一化 0~1;
+- 输出:`[1, 9, 8400]`(8400 = 各尺度 anchor 数,9 = 4 个框坐标 cx/cy/w/h + 5 类得分),展平为 `9 * 8400` 个 float。
+
+### 3.5 detection 模块 — 后处理(解码 + NMS)
+
+```kotlin
+private fun postprocess(raw: FloatArray, imgW: Int, imgH: Int): List {
+ val numAnchor = raw.size / 9
+ val boxes = mutableListOf()
+
+ for (a in 0 until numAnchor) {
+ val cx = raw[a]; val cy = raw[9 + a]
+ val w = raw[18 + a]; val h = raw[27 + a]
+ var bestCls = 0; var bestScore = 0f
+ for (c in 0 until 5) {
+ val s = raw[36 + c * numAnchor + a] // 类别得分按 anchor 平铺
+ if (s > bestScore) { bestScore = s; bestCls = c }
+ }
+ if (bestScore < confThreshold) continue
+ boxes += DetectionResult(
+ label = labels[bestCls],
+ score = bestScore,
+ // 归一化坐标(裁剪到 [0,1])
+ left = (cx - w / 2).coerceIn(0f, 1f),
+ top = (cy - h / 2).coerceIn(0f, 1f),
+ right = (cx + w / 2).coerceIn(0f, 1f),
+ bottom = (cy + h / 2).coerceIn(0f, 1f),
+ )
+ }
+ return nms(boxes, iouThreshold)
+}
+
+fun nms(boxes: List, iouThreshold: Float): List {
+ val sorted = boxes.sortedByDescending { it.score }
+ val kept = mutableListOf()
+ for (b in sorted) {
+ if (kept.none { iou(b, it) > iouThreshold }) kept += b
+ }
+ return kept
+}
+```
+
+注意:以上取数索引为示意,**必须与模型导出时的输出布局(yolov8 tflite 为 9×8400,按列平铺)核对一致**,建议训练导出后用 Python 脚本先对单图做一致性校验再接入 App。
+
+### 3.6 distance 模块 — DistanceEstimator.kt
+
+```kotlin
+class DistanceEstimator(context: Context) {
+
+ private val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
+ private var focalPxCache = -1f
+
+ // 物种参考体型(米),用于针孔模型估算
+ private val speciesSizeM = mapOf(
+ "pheasant" to 0.45f, // 身高
+ )
+
+ /** 距离 = 焦距px × 参考体型 / 框高px(在分析图像分辨率下计算) */
+ fun estimate(label: String, boxHeightNorm: Float, imageHeightPx: Int): Float? {
+ val realH = speciesSizeM[label] ?: return null
+ val boxH = boxHeightNorm * imageHeightPx
+ if (boxH < 8f) return null // 过小目标不估算
+ val focalPx = focalPx(imageHeightPx)
+ if (focalPx <= 0) return null
+ return round(focalPx * realH / boxH)
+ }
+
+ /** focal_px = focal_mm × (imageHeightPx / sensorHeightMm);视场角作回退 */
+ private fun focalPx(imageHeightPx: Int): Float {
+ if (focalPxCache > 0) return focalPxCache
+ val c = cameraManager.getCameraCharacteristics(cameraManager.cameraIdList.first())
+ val focalMm = c.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)?.firstOrNull()
+ val sensor = c.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE)
+ val fov = c.get(CameraCharacteristics.LENS_INFO_HORIZONTAL_VIEW_ANGLE)
+ focalPxCache = when {
+ focalMm != null && sensor != null -> focalMm * imageHeightPx / sensor.height
+ fov != null -> (imageHeightPx / 2f) / tan(fov / 2f)
+ else -> -1f
+ }
+ return focalPxCache
+ }
+}
+```
+
+说明:
+
+- 前后摄切换时需按当前相机重新获取焦距(缓存按相机 ID 区分);
+- 距离估算仅供参考,实际距离可能因环境因素有所偏差。
+
+### 3.7 detection 模块 — CoordinateMapper.kt
+
+模型输出为归一化坐标(相对分析图像,竖屏方向),需转换到预览视图坐标:
+
+```kotlin
+class CoordinateMapper(private val view: PreviewView) {
+
+ /** 归一化坐标 → 预览视图像素坐标(考虑传感器旋转与 FIT_CENTER 裁剪) */
+ fun mapToView(norm: DetectionResult, rotation: Int): RectF {
+ // 1) 旋转校正:把"图像方向"归一化坐标转到"竖屏视图方向"
+ val (x0, y0, x1, y1) = rotate(norm, rotation)
+ // 2) 处理 FIT_CENTER 的裁剪偏移与缩放
+ val viewW = view.width; val viewH = view.height
+ val scale = min(viewW / bitmapW, viewH / bitmapH) // bitmap 尺寸
+ val offsetX = (viewW - bitmapW * scale) / 2f
+ val offsetY = (viewH - bitmapH * scale) / 2f
+ return RectF(
+ x0 * bitmapW * scale + offsetX,
+ y0 * bitmapH * scale + offsetY,
+ x1 * bitmapW * scale + offsetX,
+ y1 * bitmapH * scale + offsetY,
+ )
+ }
+}
+```
+
+### 3.8 overlay 模块 — DetectionOverlay.kt
+
+Compose Canvas 叠加层:
+
+```kotlin
+@Composable
+fun DetectionOverlay(results: List, rotation: Int, modifier: Modifier) {
+ val mapper = remember { CoordinateMapper(view) }
+ Canvas(modifier = modifier) {
+ results.forEach { r ->
+ val rect = mapper.mapToView(r, rotation)
+ drawRect(
+ color = colorOf(r.label),
+ topLeft = Offset(rect.left, rect.top),
+ size = Size(rect.width(), rect.height()),
+ style = Stroke(6.dp.toPx()),
+ )
+ // 距离由 ViewModel 计算后写入 DetectionResult.distance(Float?)
+ val dist = r.distance?.let { " · 约${it}m" } ?: ""
+ drawText("${r.label} ${(r.score * 100).toInt()}%$dist")
+ }
+ }
+}
+```
+
+防闪烁:ViewModel 中对结果做"目标停留 ≥ 0.5s 才显示、消失 2s 后移除"的平滑处理。
+
+### 3.9 reminder 模块 — Reminder.kt
+
+```kotlin
+class Reminder(private val context: Context) {
+ private val vibrator = context.getSystemService(Vibrator::class.java)
+ private var lastAlertAt = 0L
+ private var lastAlertLabel: String? = null
+
+ /** 同类目标 10s 内只提醒一次 */
+ fun onDetected(label: String) {
+ val now = SystemClock.elapsedRealtime()
+ if (label == lastAlertLabel && now - lastAlertAt < 10_000) return
+ lastAlertAt = now
+ lastAlertLabel = label
+ vibrator?.vibrate(VibrationEffect.createOneShot(200, VibrationEffect.DEFAULT_AMPLITUDE))
+ }
+}
+```
+
+### 3.10 设置存储 — DataStore
+
+使用 DataStore Preferences 保存:`conf_threshold`(默认 0.40)、`detect_mode`(连续/标准/省电)、`vibrate_enabled`、`sound_enabled`、`low_light_enhance`(P2)、`show_distance`(默认开)。`SettingsViewModel` 以 Flow 暴露,`FrameAnalyzer` 与 `TFLiteDetector` 读取最新值。
+
+---
+
+## 4. 关键流程实现
+
+### 4.1 启动流程
+
+```
+MainActivity.onCreate
+→ 检查/请求 CAMERA 权限
+→ 初始化 TFLiteDetector(后台线程,含预热推理)
+→ CameraController.start(previewView, analyzer)
+→ ViewModel 订阅检测结果 → 叠加层渲染 + 提醒触发
+```
+
+预热:加载模型后执行一次空推理(全零输入),避免首帧卡顿。
+
+### 4.2 帧节流与并发控制
+
+- 检测间隔:连续 80~100ms / 标准 300ms / 省电 1000ms(由设置决定);
+- 推理在单线程 Executor(HandlerThread)串行执行,天然互斥;
+- 分析线程只做"取帧 → 判断节流 → 提交任务",不做推理,保证预览流畅。
+
+### 4.3 提醒、距离标注与防闪烁
+
+- 检测到目标(置信度 ≥ 阈值,生境区域用独立阈值)→ `Reminder.onDetected(label)`,10s 防重复,动物与生境预警提醒方式区分;
+- 叠加层展示逻辑:目标连续出现 ≥ 0.5s 才绘制;消失后 2s 内不清除,避免闪烁;
+- 低置信度结果(阈值以下)不展示、不提醒;
+- 距离标注:结果到达后调用 `DistanceEstimator` 估算"约 X m",随检测框渲染(FrameAnalyzer 回调携带分析图像高度);生境区域同样估算(按参考植被高度,误差较大);框高过小(< 8px)或无法获取焦距显示"--"。
+
+---
+
+## 5. 模型集成细节
+
+| 项 | 说明 |
+| --- | --- |
+| 模型文件 | `app/src/main/assets/model.tflite`,随 APK 打包 |
+| 标签文件 | `assets/labels.txt`,每行一个类别,顺序与训练一致(pheasant) |
+| 量化 | 优先 fp16(GPU);int8 用于 CPU/低端机回退 |
+| 加载 | `Interpreter(loadAssetFile(...))`,进程内单例 |
+| 预热 | 初始化后执行一次 dummy run |
+| 校验 | 发布前用 Python 脚本(tflite-runtime)对测试集抽样验证模型输出布局与 App 解析一致 |
+
+---
+
+## 6. 性能优化实践
+
+| 手段 | 说明 |
+| --- | --- |
+| GPU Delegate | 优先启用;`addDelegate` 抛异常或首帧异常时回退 CPU,并上报降级日志 |
+| 输入缓冲复用 | ByteBuffer 复用,避免每次检测重新分配 |
+| 帧节流 | 按模式降频,KEEP_ONLY_LATEST 不积压 |
+| 位图复用 | ImageProxy → Bitmap 走共享缓冲;叠加层仅绘制检测框,不复制整帧 |
+| 线程模型 | 检测线程单线程串行;UI 线程零推理 |
+| 低分辨率检测 | 320 输入检测;远距离模式可切 416(V2) |
+| 距离标注 | 纯算术运算开销可忽略;焦距 px 按相机缓存复用,避免重复查询 CameraCharacteristics |
+| 生命周期 | 后台自动 `unbind` 相机释放资源,避免耗电 |
+| 省电模式 | 降到 1 帧/秒 + 低分辨率 + 关闭提醒以外的动画 |
+
+---
+
+## 7. 异常与降级策略
+
+| 异常场景 | 处理 |
+| --- | --- |
+| GPU 不可用 / 模型加载失败 | 回退 CPU 线程数调整;失败则提示"识别不可用"但不影响相机预览 |
+| 相机初始化失败(个别机型) | 重试一次 → 失败提示并引导检查权限/重启 |
+| 权限被拒 | 引导页说明用途,提供跳转系统设置 |
+| 低内存 | 主动释放缓存位图,降低检测分辨率 |
+| 推理超时(> 500ms) | 丢弃该帧结果,恢复下一帧,保证预览流畅 |
+| 无法读取焦距 / 视场角(个别机型) | 距离显示"--",识别不受影响 |
+
+---
+
+## 8. 测试方案
+
+### 8.1 单元测试(JUnit)
+
+- `NmsTest`:NMS 正确性(重叠/多类别/边界框);
+- `CoordinateMapperTest`:四向旋转映射、FIT_CENTER 裁剪偏移;
+- `ThresholdTest`:置信度阈值过滤逻辑;
+- `ReminderTest`:10s 防重复提醒逻辑、动物与生境提醒方式区分;
+- `DistanceEstimatorTest`:焦距换算、距离公式、过小目标不估算、生境区域按植被高度估算。
+
+### 8.2 模型评估(Python 脚本,独立于 App)
+
+- 对测试集计算 mAP@0.5、mAP@0.5:0.95、各类别 AP、负样本误检率;
+- 输出混淆矩阵,分析检测精度。
+
+### 8.3 仪器测试(androidTest)
+
+- 模拟帧注入:将测试图片经 `ImageProxy` 注入分析器,断言结果(不依赖真机取景);
+- 权限拒绝 / 恢复场景;
+- 冷启动到预览的时间基准测试。
+
+### 8.4 真机测试矩阵
+
+| 机型档位 | 代表机型 | 验证项 |
+| --- | --- | --- |
+| 旗舰 | 骁龙 8 系 / 麒麟 9 系 | 全功能、GPU 路径、长时间发热 |
+| 中端 | 骁龙 7 系 / 天玑 8 系 | 延迟 ≤ 80ms、耗电 ≤ 15%/h |
+| 低端 | 骁龙 4 系 / 天玑 6 系 | CPU 回退路径、省电模式可用性 |
+
+---
+
+## 9. 构建与发布
+
+```kotlin
+android {
+ compileSdk = 35
+ defaultConfig {
+ applicationId = "com.example.observer"
+ minSdk = 24
+ targetSdk = 35
+ ndk { abiFilters += listOf("arm64-v8a") } // 主发 arm64;如需兼容 32 位另发包
+ }
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+}
+```
+
+- R8 规则:保留 TFLite 相关类(`-keep class org.tensorflow.** { *; }`),assets 模型不混淆;
+- 签名:正式签名 + Gradle 管理(或环境变量注入),不上传 keystore 到仓库;
+- 上架检查:隐私政策(仅相机权限、不采集与存储数据声明)、权限用途说明。
+
+---
+
+## 10. 后续演进(V2 方向)
+
+- 更多物种类别(哺乳类、鸟类细分),支持模型远程更新;
+- 小目标优化:SAHI 切图推理 / 专用小目标模型 / 数字变焦辅助;
+- 夜视 / 红外增强模式;
+- 检测结果语音播报,提升无障碍体验;
+- 距离精度增强:基于设备俯仰角与相机高度的地面平面法、镜头畸变校正、水下折射修正;
+- 生境类别细分与精度提升(湿地、林缘等),生境预警策略优化;
+- 若未来需要留存能力(拍照、记录),可基于现有检测链路平滑扩展。
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..f0a2e55
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..407954b
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,39 @@
+[versions]
+agp = "8.5.2"
+kotlin = "2.0.20"
+coreKtx = "1.13.1"
+lifecycle = "2.8.6"
+activityCompose = "1.9.2"
+composeBom = "2024.09.03"
+camerax = "1.4.1"
+tflite = "2.16.1"
+datastore = "1.1.1"
+coroutines = "1.9.0"
+junit = "4.13.2"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
+androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" }
+androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
+androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
+androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
+org-tensorflow-lite = { group = "org.tensorflow", name = "tensorflow-lite", version.ref = "tflite" }
+org-tensorflow-lite-gpu = { group = "org.tensorflow", name = "tensorflow-lite-gpu", version.ref = "tflite" }
+androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000000000000000000000000000000000000..eddabd2eef8d94a5437d6168ff9c87a78ff725b3
GIT binary patch
literal 47505
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