初始化 observer 项目:纯代码,不含权重与训练数据

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