训练体系整合与标注单阶段化

- 标注:AI 预标注直写 labels_json(去候选确认两阶段);重叠去重(minIoU);全量标注按钮
- 训练:脚本迁移入 server/training/(Go 化 prepare_yolo/analyze_rfdetr,保留 train_server.py);tflite 产物自检并入训练流程(check_tflite)
- 数据目录/权重不进 git;.gitignore 迁移至仓库根
This commit is contained in:
2026-08-26 18:22:56 +08:00
parent 4f39e85882
commit a0b115d954
108 changed files with 10877 additions and 798 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ app.*.symbols
# Obfuscation related
app.*.map.json
observer-latest.apk
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
+28 -4
View File
@@ -1,6 +1,6 @@
# observer
野生动物实时识别 AppFlutter 版)。Android / iOS 一套代码,后端接口与支付见
动物实时识别 AppFlutter 版)。Android / iOS 一套代码,后端接口与支付见
[`docs/PaymentApi.md`](docs/PaymentApi.md)。
## iOS 真机部署(iPhone
@@ -8,9 +8,10 @@
### 构建与安装
```bash
# 真机必须传 Mac 局域网 IP:默认 API_BASE_URL 是 10.0.2.2(仅 Android 模拟器可用),
# 不传则 iPhone 上所有网络请求(登录/授权/套餐)都会失败
flutter build ios --release --dart-define=API_BASE_URL=http://<Mac局域网IP>:8080
# 生产包直接构建即可:默认 API_BASE_URL 为线上域名(lib/config/app_config.dart),无需传参
flutter build ios --release
# 仅本地联调(后端跑在 Mac 上、手机连同一 Wi-Fi)时才覆盖为 Mac 局域网 IP
# flutter build ios --release --dart-define=API_BASE_URL=http://<Mac局域网IP>:8080
# 安装到真机(UDID 可用 `xcrun devicectl list devices` 查询)
xcrun devicectl device install app --device <UDID> build/ios/iphoneos/Runner.app
@@ -26,6 +27,29 @@ xcrun devicectl device process launch --console --terminate-existing \
"In iOS 14+, debug mode Flutter apps can only be launched from Flutter tooling"。
debug 调试必须用 `flutter run -d <设备ID>` 或 Xcode IDE 启动(`flutter devices` 查设备ID);
从图标启动只对 release 构建有效。
## 模型热更新(多数据集模型)
模型与 APK 更新走**独立通道**:启动时拉取 `GET /api/v1/app/update` 随附的
`models` 目录(公开接口,无需登录),与 `UpdateChecker` 的 APK 检查并行。
- **目录条目**`{datasetId, datasetName, version, labels[], sizeBytes, sha256,
downloadUrl}`;服务器未发布模型时不返回 `models` 字段,App 回退内置
`assets/model.tflite`(标签 `内置`)。
- **存储**:应用私有目录 `models/<datasetId>/`,含 `model.tflite`、
`labels.json`、`meta.json`meta 记录 `{version, sha256}`)。版本与摘要都
未变化时跳过下载;变化则下载到 `.part` 临时文件、sha256 校验通过后
原子 rename 替换,失败重试一次并保留旧模型,下次启动再试。
- **清理**:服务器下线的数据集下次同步时删除本地对应目录。
- **并行推理合并**:识别时加载全部已下载模型(`DetectorWorker` isolate 内
逐模型加载,单个失败不影响其他),同帧各模型独立推理后按类别分组做
**跨模型 NMS**(同类别不同模型检出同一目标取高分去重,不同类别互不压制),
结果叠加 `modelName` 标注来源,内置模型兜底。
实现:`lib/models/model_manager.dart`(下载/校验/持久化,`ModelManager`
单例 + ChangeNotifier)、`lib/detection/detector_worker.dart`(多模型并行
推理与 `mergeAcrossModels`)、`lib/camera/camera_screen.dart`(启动同步 +
诊断行展示模型列表)。
- **模型输入是 NHWC**`assets/model.tflite` 做过字节级手术(开头 TRANSPOSE→RESHAPE
输入 [1,320,320,3]),改动记录见 git 历史,重导模型需同步处理,否则 iOS 报
"Node number 0 (TRANSPOSE) failed to prepare"。
+4
View File
@@ -40,6 +40,10 @@ kotlin {
}
}
// 相机用 Android 框架 camera2 API 完全自研(CameraChannel.kt):
// 预览 SurfaceTextureFlutter 纹理)+ ImageReader 分析帧(原生侧旋转成竖屏后回传),
// 不依赖任何相机三方库(含 CameraX)。
// tflite_flutter 依赖的 tensorflow-lite / tensorflow-lite-gpu / tensorflow-lite-api 三个 AAR
// 声明了相同 namespace(org.tensorflow.lite),新 AGP 视作冲突直接报错;
// 本项目仅用 CPU 推理,GPU delegate 未使用,排除 gpu 及其传递依赖的 api 即可。
+13
View File
@@ -0,0 +1,13 @@
# R8 release 压缩:okhttp 可选 TLS 平台类(BouncyCastle/Conscrypt/OpenJSSE
# tflite_flutter 的反射注解类未打包,仅需忽略引用告警(AGP missing_rules.txt 生成)
-dontwarn org.bouncycastle.jsse.BCSSLParameters
-dontwarn org.bouncycastle.jsse.BCSSLSocket
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
-dontwarn org.conscrypt.Conscrypt$Version
-dontwarn org.conscrypt.Conscrypt
-dontwarn org.conscrypt.ConscryptHostnameVerifier
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
-dontwarn org.openjsse.net.ssl.OpenJSSE
-dontwarn org.tensorflow.lite.InterpreterFactoryApi
-dontwarn org.tensorflow.lite.annotations.UsedByReflection
@@ -1,6 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.INTERNET"/>
<!-- App 内更新安装 APKPackageInstaller 会话安装,见 InstallerChannel.kt -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<application
android:label="视野"
android:name="${applicationName}"
@@ -0,0 +1,568 @@
package com.example.observer
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.SurfaceTexture
import android.os.Build
import android.hardware.camera2.CameraCaptureSession
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraDevice
import android.hardware.camera2.CameraManager
import android.hardware.camera2.CaptureRequest
import android.graphics.ImageFormat
import android.media.Image
import android.media.ImageReader
import android.os.Handler
import android.os.HandlerThread
import android.os.SystemClock
import android.util.Log
import android.util.Size
import android.view.Surface
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.view.TextureRegistry
/**
* 自写原生相机:Android 框架 camera2 API 完全自研,零相机三方依赖(含 CameraX)。
*
* 核心:分析帧在原生侧旋转成竖屏方向再回传,Flutter 侧恒用 rotation=0
* 与 iOScamera_avfoundation,帧本来就是竖屏)行为对齐,彻底消除
* "横屏传感器帧 → 90° 旋转 + FIT_COVER crop 映射"的标注偏移根因。
*
* 旋转角来自设备标准值 sensorOrientation - displayRotationcamera2 特性,
* 非厂商 hack),因此映射跨厂商一致:任何设备上"分析帧 = 预览帧同 sensor
* 同旋转",两者几何必然一致。
*
* 预览:SurfaceTexture 注册进 FlutterTextureRegistryFlutter 侧 Texture widget
* 渲染(与插件 CameraPreview 相同的合成方式,overlay/诊断行/按钮可叠加;
* AndroidView+SurfaceView 会盖住 Flutter UI,不可用)。
*
* 通道:
* - MethodChannel "observer/camera"start/stop/getZoomRange/setZoom/isStreaming/errorDescription
* - EventChannel "observer/camera/frames":每帧 [width, height, rotationDegrees, bgra, bytes]
* - rotationDegrees 恒 0(帧已竖屏);bgra=falseRGBA 字节序)
*/
class CameraChannel(
private val activity: FlutterActivity,
private val engine: FlutterEngine,
) {
private val messenger = engine.dartExecutor.binaryMessenger
private val method = MethodChannel(messenger, "observer/camera")
private val frames = EventChannel(messenger, "observer/camera/frames")
private val cameraManager: CameraManager = activity.getSystemService(CameraManager::class.java)
private var cameraDevice: CameraDevice? = null
private var captureSession: CameraCaptureSession? = null
private var imageReader: ImageReader? = null
private var surfaceEntry: TextureRegistry.SurfaceTextureEntry? = null
private var previewSurface: Surface? = null
private var eventSink: EventChannel.EventSink? = null
private var sensorOrientation = 90
private var activeArray = Rect(0, 0, 1920, 1080)
private var maxZoom = 1.0f
/// 预览/分析共用分辨率(传感器方向,从设备流配置动态选择)
private var previewSize = Size(1920, 1080)
@Volatile
private var running = false
@Volatile
private var errorDescription: String? = null
private val mainHandler = Handler(activity.mainLooper)
private val analysisThread = HandlerThread("observer-analysis").also { it.start() }
private val analysisHandler = Handler(analysisThread.looper)
@Volatile
private var lastEmitMs = 0L
// 诊断计数(frameListener 线程写,stats 轮询读):
// 帧回调到达次数 / 成功发出 / 发出异常 / 无订阅者丢弃
@Volatile
private var frameCallbacks = 0L
@Volatile
private var emitOk = 0L
@Volatile
private var emitErr = 0L
@Volatile
private var sinkNullCount = 0L
// 实际发出帧尺寸(缩小后)
@Volatile
private var emitW = 0
@Volatile
private var emitH = 0
fun register() {
method.setMethodCallHandler(::onMethodCall)
frames.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
}
override fun onCancel(arguments: Any?) {
eventSink = null
}
})
}
private fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"start" -> start(result)
"stop" -> {
stop()
result.success(true)
}
"getZoomRange" -> result.success(listOf(1.0, maxZoom.toDouble()))
"setZoom" -> {
val v = (call.arguments as Number).toFloat()
setZoom(v)
result.success(true)
}
"isStreaming" -> result.success(running)
"errorDescription" -> result.success(errorDescription)
"stats" -> result.success(
mapOf(
"callbacks" to frameCallbacks,
"emitOk" to emitOk,
"emitErr" to emitErr,
"sinkNull" to sinkNullCount,
"sink" to (eventSink != null),
"running" to running,
"rotation" to rotation,
"quarterTurns" to displayDegrees / 90,
"displayDegrees" to displayDegrees,
"size" to "${previewSize.width}x${previewSize.height}",
"emitSize" to "${emitW}x$emitH",
"emitAgeMs" to
if (lastEmitMs == 0L) -1L
else SystemClock.elapsedRealtime() - lastEmitMs,
"error" to errorDescription,
),
)
else -> result.notImplemented()
}
}
private fun start(result: MethodChannel.Result) {
if (running) {
result.success(true)
return
}
running = true
errorDescription = null
try {
val cameraId = pickBackCamera() ?: throw IllegalStateException("未找到后置摄像头")
val characteristics = cameraManager.getCameraCharacteristics(cameraId)
sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 90
activeArray = characteristics.get(
CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE,
) ?: Rect(0, 0, 1920, 1080)
maxZoom = characteristics.get(CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM) ?: 1.0f
// 分辨率不写死:从设备流配置查询(传感器方向尺寸,宽≥高),
// 预览与分析共用同一尺寸,保证两者几何一致(对齐根因)。
// 查询是优化而非必需:个别设备 getOutputSizes 会返回 null 或抛异常
// (实测某设备内部抛 getClass NPE),失败一律回退默认分辨率,
// 绝不让相机启动失败
try {
val streamMap = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
if (streamMap != null) {
val previewSizes =
streamMap.getOutputSizes(SurfaceTexture::class.java) ?: emptyArray()
val analysisSizes =
streamMap.getOutputSizes(ImageFormat.YUV_420_888) ?: emptyArray()
previewSize = pickSize(previewSizes, analysisSizes)
}
} catch (e: Exception) {
Log.w(TAG, "size query failed, fallback ${previewSize.width}x${previewSize.height}", e)
}
val entry = surfaceEntry ?: engine.getRenderer().createSurfaceTexture().also {
surfaceEntry = it
}
// 关键:SurfaceTexture 未设默认 buffer 尺寸时 createCaptureSession 会配置失败
// camera2 按该尺寸做 stream 校验)。显式指定与 ImageReader 一致的分辨率,
// 保证预览与分析帧同分辨率同裁剪
entry.surfaceTexture().setDefaultBufferSize(previewSize.width, previewSize.height)
previewSurface?.release()
previewSurface = Surface(entry.surfaceTexture())
imageReader?.close()
// YUV_420_888 是 camera2 对所有设备保证支持的 ImageReader 输出格式;
// RGBA_8888 个别设备不支持(实测:查询 NPE + 配置失败双连败),弃用
imageReader = ImageReader.newInstance(
previewSize.width, previewSize.height, ImageFormat.YUV_420_888, 2,
).also { it.setOnImageAvailableListener(frameListener, analysisHandler) }
cameraManager.openCamera(
cameraId,
object : CameraDevice.StateCallback() {
override fun onOpened(device: CameraDevice) {
cameraDevice = device
createSession(result)
}
override fun onDisconnected(device: CameraDevice) {
device.close()
cameraDevice = null
}
override fun onError(device: CameraDevice, error: Int) {
device.close()
cameraDevice = null
running = false
errorDescription = "camera open error $error"
result.error("start", errorDescription, null)
}
},
mainHandler,
)
} catch (e: Exception) {
running = false
errorDescription = e.toString()
// details 带完整堆栈:任何残留异常都能在诊断行看到精确位置
result.error("start", e.toString(), Log.getStackTraceString(e))
}
}
private fun createSession(result: MethodChannel.Result) {
val device = cameraDevice ?: return
val surfaces = listOfNotNull(previewSurface, imageReader?.surface)
device.createCaptureSession(
surfaces,
object : CameraCaptureSession.StateCallback() {
override fun onConfigured(session: CameraCaptureSession) {
captureSession = session
try {
val request = buildRequest(session.device, surfaces)
session.setRepeatingRequest(request, null, mainHandler)
result.success(
mapOf(
"textureId" to (surfaceEntry?.id() ?: -1L),
// 传感器方向尺寸(宽≥高):Flutter 侧 SizedBox 在 RotatedBox
// 内部声明纹理尺寸,旋转后视觉上才是竖屏 1080x1920
"w" to previewSize.width,
"h" to previewSize.height,
// 预览旋转 = 显示旋转(不是 rotation/90!):
// Flutter 引擎渲染 Texture 时已自动应用 SurfaceTexture
// 变换矩阵(传感器方向补偿),预览只需再按显示旋转补偿
"quarterTurns" to displayDegrees / 90,
// 诊断:旋转计算输入值
"sensorOrientation" to sensorOrientation,
"displayDegrees" to displayDegrees,
),
)
} catch (e: Exception) {
errorDescription = "onConfigured: $e"
result.error("start", errorDescription, Log.getStackTraceString(e))
}
}
override fun onConfigureFailed(session: CameraCaptureSession) {
running = false
errorDescription = "capture session configure failed"
result.error("start", errorDescription, null)
}
},
mainHandler,
)
}
private fun buildRequest(device: CameraDevice, targets: List<Surface>): CaptureRequest {
val builder = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
targets.forEach { builder.addTarget(it) }
builder.set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO)
// 连续对焦:无 AF 能力的设备忽略该设置
try {
builder.set(
CaptureRequest.CONTROL_AF_MODE,
CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE,
)
} catch (_: IllegalArgumentException) {
}
return builder.build()
}
private fun setZoom(z: Float) {
val session = captureSession ?: return
val clamped = z.coerceIn(1.0f, maxZoom)
val r = activeArray
val insetW = r.width() * (1 - 1 / clamped) / 2
val insetH = r.height() * (1 - 1 / clamped) / 2
val crop = RectF(
r.left + insetW,
r.top + insetH,
r.right - insetW,
r.bottom - insetH,
)
try {
val surface = previewSurface ?: return
val builder = session.device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
builder.addTarget(surface)
imageReader?.surface?.let { builder.addTarget(it) }
builder.set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO)
builder.set(
CaptureRequest.SCALER_CROP_REGION,
Rect(
crop.left.toInt(),
crop.top.toInt(),
crop.right.toInt(),
crop.bottom.toInt(),
),
)
session.setRepeatingRequest(builder.build(), null, mainHandler)
} catch (e: Exception) {
Log.w(TAG, "setZoom failed", e)
}
}
private fun stop() {
running = false
try {
captureSession?.close()
} catch (_: Exception) {
}
captureSession = null
try {
cameraDevice?.close()
} catch (_: Exception) {
}
cameraDevice = null
}
fun destroy() {
stop()
imageReader?.close()
imageReader = null
previewSurface?.release()
previewSurface = null
surfaceEntry?.release()
surfaceEntry = null
analysisThread.quitSafely()
}
private fun pickBackCamera(): String? {
val ids = cameraManager.cameraIdList
for (id in ids) {
val c = cameraManager.getCameraCharacteristics(id)
val facing = c.get(CameraCharacteristics.LENS_FACING)
if (facing == CameraCharacteristics.LENS_FACING_BACK) return id
}
return ids.firstOrNull()
}
/**
* 从设备流配置挑预览/分析共用分辨率(均为传感器方向尺寸,宽≥高):
* 取两者交集,16:9 优先、长边 ≤1920 内取最大(推理输入 640x640,
* 更高只增帧传输与旋转开销,无精度收益);无 16:9 时退回最大交集尺寸。
*/
private fun pickSize(previewSizes: Array<Size>, analysisSizes: Array<Size>): Size {
val common = previewSizes.filter { analysisSizes.contains(it) }
val fallback = common.maxByOrNull { it.width.toLong() * it.height }
?: return Size(1920, 1080)
return common
.filter {
Math.abs(it.width.toDouble() / it.height - 16.0 / 9.0) < 0.03 &&
it.width <= 1920 && it.height <= 1920
}
.maxByOrNull { it.width.toLong() * it.height }
?: fallback
}
/**
* 显示旋转角(度)。Display.ROTATION_* 在 API 36 是受限常量,直接用其值(0/1/2/3)。
* API 30+ 用 activity.display(现代 API,实测可靠跟踪旋转);
* 旧版 windowManager.defaultDisplay.rotation 已废弃,实测在部分设备恒返回 0。
*/
private val displayDegrees: Int
get() = try {
val rot: Int = if (Build.VERSION.SDK_INT >= 30) {
activity.display?.rotation ?: 0
} else {
@Suppress("DEPRECATION")
activity.windowManager.defaultDisplay.rotation
}
when (rot) {
1 -> 90
2 -> 180
3 -> 270
else -> 0
}
} catch (e: Exception) {
Log.w(TAG, "display rotation query failed", e)
0
}
/** 传感器 → 竖屏显示的顺时针旋转角(后摄无镜像) */
private val rotation: Int
get() = ((sensorOrientation - displayDegrees) % 360 + 360) % 360
private val frameListener = ImageReader.OnImageAvailableListener { reader ->
// 诊断计数:回调是否到达(放在一切判定之前,任何丢弃都先计数)
frameCallbacks++
if (frameCallbacks % 50 == 0L) {
Log.i(TAG, "frames cb=$frameCallbacks ok=$emitOk err=$emitErr sinkNull=$sinkNullCount")
}
val image = reader.acquireLatestImage() ?: return@OnImageAvailableListener
try {
val sink = eventSink
if (sink == null) {
// 诊断:有帧但无订阅者(Dart 侧订阅未建立/被取消)
sinkNullCount++
errorDescription = "帧监听无订阅者"
return@OnImageAvailableListener
}
if (!running) return@OnImageAvailableListener
val now = SystemClock.elapsedRealtime()
// 节流:推理 ~100ms 一帧,避免数 MB 帧无谓传输
if (now - lastEmitMs < 100) return@OnImageAvailableListener
lastEmitMs = now
val bytes = yuvToRgba(image, rotation)
// 缩小后再发:8.3MB/帧对主线程编码与通道传输都过重,540x960 足够
// (推理输入 704,坐标归一化,映射不受分辨率影响)
val scaled = downscaleToFit(
bytes[0] as Int, bytes[1] as Int, bytes[2] as ByteArray, 960,
)
emitW = scaled[0] as Int
emitH = scaled[1] as Int
if (emitOk == 0L) Log.i(TAG, "first emit ${emitW}x${emitH}")
// 关键:EventSink.success 内部 FlutterJNI.dispatchPlatformMessage 强制主线程
// ensureRunningOnMainThread 抛 RuntimeException),后台线程直接调必失败——
// 曾因此每帧抛"必须主线程"异常、Dart 侧永远 流:0。必须 post 到主线程发送
mainHandler.post {
try {
sink.success(listOf(scaled[0], scaled[1], 0, false, scaled[2]))
emitOk++
} catch (e: Exception) {
emitErr++
Log.e(TAG, "emit frame failed", e)
errorDescription = "帧异常: ${e.javaClass.simpleName}: ${e.message}"
}
}
} catch (e: Exception) {
emitErr++
Log.e(TAG, "emit frame failed", e)
errorDescription = "帧异常: ${e.javaClass.simpleName}: ${e.message}"
} finally {
image.close()
}
}
/**
* YUV_420_888 三平面 → RGBA 单平面,同时顺时针旋转成竖屏。
* 返回 [width, height, bytes]。整数 BT.601 转换;U/V 兼容 planar
* pixelStride=1)与 semi-planarpixelStride=2NV21 式交错)布局。
* dst(x', y') = src(y, W-1-x')90° 顺时针),逐像素处理 rowStride 填充。
*/
private fun yuvToRgba(image: Image, deg: Int): List<Any> {
val yPlane = image.planes[0]
val uPlane = image.planes[1]
val vPlane = image.planes[2]
val srcW = image.width
val srcH = image.height
val yStride = yPlane.rowStride
val uStride = uPlane.rowStride
val vStride = vPlane.rowStride
val uPixel = uPlane.pixelStride
val vPixel = vPlane.pixelStride
val yBuf = yPlane.buffer
val uBuf = uPlane.buffer
val vBuf = vPlane.buffer
val dstW = if (deg == 90 || deg == 270) srcH else srcW
val dstH = if (deg == 90 || deg == 270) srcW else srcH
val dst = ByteArray(dstW * dstH * 4)
for (dy in 0 until dstH) {
val rowBase = dy * dstW * 4
for (dx in 0 until dstW) {
val sx: Int
val sy: Int
when (deg) {
90 -> {
sx = dy
sy = srcH - 1 - dx
}
180 -> {
sx = srcW - 1 - dx
sy = srcH - 1 - dy
}
270 -> {
sx = srcW - 1 - dy
sy = dx
}
else -> {
sx = dx
sy = dy
}
}
val yy = (yBuf.get(sy * yStride + sx).toInt() and 0xFF) - 16
val uu = (uBuf.get((sy / 2) * uStride + (sx / 2) * uPixel).toInt() and 0xFF) - 128
val vv = (vBuf.get((sy / 2) * vStride + (sx / 2) * vPixel).toInt() and 0xFF) - 128
val r = ((298 * yy + 409 * vv + 128) shr 8).coerceIn(0, 255)
val g = ((298 * yy - 100 * uu - 208 * vv + 128) shr 8).coerceIn(0, 255)
val b = ((298 * yy + 516 * uu + 128) shr 8).coerceIn(0, 255)
val di = rowBase + dx * 4
dst[di] = r.toByte()
dst[di + 1] = g.toByte()
dst[di + 2] = b.toByte()
dst[di + 3] = 0xFF.toByte()
}
}
return listOf(dstW, dstH, dst)
}
/**
* RGBA 帧整数倍缩小,长边 ≤ maxLong 时原样返回(f=1)。
* 2x2 盒式平均(对 640x640 推理输入足够;比最近邻平滑,颜色更准)。
*/
private fun downscaleToFit(w: Int, h: Int, rgba: ByteArray, maxLong: Int): List<Any> {
val long = maxOf(w, h)
if (long <= maxLong) return listOf(w, h, rgba)
val f = (long + maxLong - 1) / maxLong
val dw = w / f
val dh = h / f
val out = ByteArray(dw * dh * 4)
for (dy in 0 until dh) {
val y0 = dy * f
val y1 = minOf(y0 + f, h)
val rows = y1 - y0
for (dx in 0 until dw) {
val x0 = dx * f
val x1 = minOf(x0 + f, w)
var r = 0L
var g = 0L
var b = 0L
var a = 0L
for (sy in y0 until y1) {
var si = sy * w * 4 + x0 * 4
for (sx in x0 until x1) {
r += rgba[si].toInt() and 0xFF
g += rgba[si + 1].toInt() and 0xFF
b += rgba[si + 2].toInt() and 0xFF
a += rgba[si + 3].toInt() and 0xFF
si += 4
}
}
val n = rows * (x1 - x0)
val di = (dy * dw + dx) * 4
out[di] = (r / n).toByte()
out[di + 1] = (g / n).toByte()
out[di + 2] = (b / n).toByte()
out[di + 3] = (a / n).toByte()
}
}
return listOf(dw, dh, out)
}
companion object {
private const val TAG = "CameraChannel"
}
}
@@ -0,0 +1,139 @@
package com.example.observer
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageInstaller
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import java.io.File
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* 原生 APK 安装通道(App 内更新安装):PackageInstaller 会话安装,
* 安装进度经 EventChannel 实时回传 Flutter(下载进度由 Flutter 侧 http 流式下载自算)。
*
* 通道:
* - MethodChannel "observer/installer"install(path)
* - result.success("installing"):已提交安装
* - result.success("permission_required"):未开「安装未知应用」,已拉起系统设置页
* - EventChannel "observer/installer/progress"{event: progress/finished/failed, ...}
*/
class InstallerChannel(
private val activity: FlutterActivity,
private val engine: FlutterEngine,
) {
private val messenger = engine.dartExecutor.binaryMessenger
private val method = MethodChannel(messenger, "observer/installer")
private val progress = EventChannel(messenger, "observer/installer/progress")
private var progressSink: EventChannel.EventSink? = null
private var activeSession: PackageInstaller.Session? = null
fun register() {
method.setMethodCallHandler { call: MethodCall, result: MethodChannel.Result ->
when (call.method) {
"install" -> install(call.argument<String>("path") ?: "", result)
else -> result.notImplemented()
}
}
progress.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
progressSink = events
}
override fun onCancel(arguments: Any?) {
progressSink = null
}
})
}
private fun install(path: String, result: MethodChannel.Result) {
val file = File(path)
if (!file.exists()) {
result.error("FILE_NOT_FOUND", "APK 文件不存在: $path", null)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
!activity.packageManager.canRequestPackageInstalls()
) {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${activity.packageName}"),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
activity.startActivity(intent)
result.success("permission_required")
return
}
try {
val pm = activity.packageManager
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
setSize(file.length())
}
val sessionId = pm.packageInstaller.createSession(params)
val session = pm.packageInstaller.openSession(sessionId)
activeSession = session
// API 36 起 registerSessionCallback(int, ...) 变体被移除,只剩全局注册形式,
// 回调按 sessionId 过滤,避免响应其他会话事件
val callback = object : PackageInstaller.SessionCallback() {
override fun onCreated(id: Int) {}
override fun onBadgingChanged(id: Int) {}
override fun onActiveChanged(id: Int, active: Boolean) {}
override fun onProgressChanged(id: Int, progressPercent: Float) {
if (id != sessionId) return
emit("progress", "progress" to progressPercent.toInt())
}
override fun onFinished(id: Int, success: Boolean) {
if (id != sessionId) return
emit("finished", "success" to success)
pm.packageInstaller.unregisterSessionCallback(this)
activeSession = null
}
}
pm.packageInstaller.registerSessionCallback(callback, Handler(Looper.getMainLooper()))
// 写 APK 到会话:1MB 缓冲流式拷贝,完成后 commit 弹系统确认框
Thread {
try {
session.openWrite("apk", 0, file.length()).use { out ->
file.inputStream().use { input ->
val buf = ByteArray(1 shl 20)
while (true) {
val n = input.read(buf)
if (n < 0) break
out.write(buf, 0, n)
}
}
}
val sender = PendingIntent.getActivity(
activity,
0,
Intent(activity, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
).intentSender
session.commit(sender)
} catch (e: Exception) {
session.abandon()
emit("failed", "error" to (e.message ?: "安装会话写入失败"))
activeSession = null
}
}.start()
result.success("installing")
} catch (e: Exception) {
result.error("INSTALL_FAILED", e.message, null)
}
}
private fun emit(event: String, vararg pairs: Pair<String, Any>) {
progressSink?.success(mapOf("event" to event, *pairs))
}
}
@@ -1,5 +1,25 @@
package com.example.observer
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
private var cameraChannel: CameraChannel? = null
private var installerChannel: InstallerChannel? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// 自写原生相机(替代 camera_android_camerax 插件):
// 分析帧在原生侧旋转成竖屏后回传,Flutter 侧恒 rotation=0(对齐 iOS)。
// configureFlutterEngineonCreate 阶段)只注册通道与 viewFactory
// 实际 bindToLifecycle 由 Flutter 相机页 start 时触发(此时已 RESUMED
cameraChannel = CameraChannel(this, flutterEngine).also { it.register() }
// App 内更新安装 APKPackageInstaller 会话安装 + 进度回传)
installerChannel = InstallerChannel(this, flutterEngine).also { it.register() }
}
override fun onDestroy() {
cameraChannel?.destroy()
super.onDestroy()
}
}
+18
View File
@@ -101,3 +101,21 @@
- `priceCents` 为整数分,客户端展示 ÷100 转元
- 展示名由客户端按 `days` 派生「N天」,接口无 label 字段
- 后端套餐来自 `config.yml` `plans` 节点(静态定价,改价改配置重启生效)
## 7. 版本更新检查
`GET /api/v1/app/update`(**公开接口,无需登录**)在 App 启动时调用(协议层见 `lib/update/update_checker.dart`)。
响应 `data`(无记录时字段为空串,视为无需更新):
```json
{
"version": "1.1.0",
"notes": "修复识别准确率问题"
}
```
- **仅 Android 调用**`UpdateChecker.fetch()` 在非 Android 平台直接返回空(iOS 不做版本下发,用户从 App Store 自行更新)
- 客户端以「语义化版本号」按数字段比较(`1.10.0 > 1.9.9`),**服务器版本 > 已装版本即强制更新**:弹全屏阻塞页(禁返回,仅「立即更新」),**App 内直接下载安装**(不跳浏览器):http 流式下载固定地址 `{apiBaseUrl}/download/observer-latest.apk`(后端静态托管,永远是最新 APK)到缓存目录(`.part` 原子落盘后改名,避免半包)→ 原生 `PackageInstaller` 会话安装(`InstallerChannel.kt`),页面实时显示**下载进度与安装进度**;首次安装需在系统设置允许「安装未知应用」(未开启时自动拉起系统设置页,APK 已缓存、返回后再次点击直达安装)
- **双版本比较防反复提示**:用户点「立即更新」时把服务器版本号写入本地(`SessionStore.accepted_update_version`);判定条件是服务器版本 > 已装版本 **或** 服务器版本 > 已接受版本。APK 版本号不递增(每次打的包 versionName 相同)时,已更新完成的手机重启也不会再次弹更新
- 网络异常/响应异常时静默跳过检查,不阻塞启动
+1 -3
View File
@@ -57,11 +57,9 @@
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>需要使用相机进行野生动物实时识别</string>
<string>需要使用相机进行动物实时识别</string>
<key>NSLocalNetworkUsageDescription</key>
<string>需要通过本地网络连接服务器进行账号验证和支付</string>
<key>UIApplicationSceneManifest</key>
+28
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import 'auth/auth_screen.dart';
@@ -7,7 +8,10 @@ import 'camera/camera_screen.dart';
import 'container.dart';
import 'home/home_screen.dart';
import 'legal/terms_screen.dart';
import 'models/model_manager.dart';
import 'payment/paywall_screen.dart';
import 'update/update_checker.dart';
import 'update/update_screen.dart';
class ObserverApp extends StatelessWidget {
final AppContainer container;
@@ -66,9 +70,33 @@ class _StartupGateState extends State<StartupGate> {
Navigator.of(context).pushReplacementNamed('/terms');
return;
}
// 版本更新检查(公开接口,无需登录态;仅 Android):服务器版本高于
// 「已装版本与已确认接受版本」中的较大者即强制更新,弹全屏阻塞页。
// APK 版本号不递增时,点过「立即更新」的 accepted 版本参与比较,
// 更新完成后再次启动不反复提示。
final info = await UpdateChecker().fetch();
final current = await PackageInfo.fromPlatform();
if (!mounted) return;
final session = context.read<SessionStore>();
final acceptedUpdate = await session.readAcceptedUpdateVersion();
if (!mounted) return;
if (UpdateChecker.needsUpdate(info.version, current.version, acceptedUpdate)) {
Navigator.of(context).pushReplacement(MaterialPageRoute(
builder: (_) => UpdateScreen(
version: info.version,
url: UpdateChecker.downloadUrl(),
notes: info.notes,
onUpdateAccepted: () =>
session.saveAcceptedUpdateVersion(info.version),
),
));
return;
}
final token = await session.readToken();
if (!mounted) return;
// 模型热更新:后台拉取模型目录并下载/更新各数据集模型(不阻塞启动,
// 相机页打开前若未就绪会兜底等待;下载失败回退内置资产模型)
ModelManager.instance.refresh();
Navigator.of(context)
.pushReplacementNamed(token == null ? '/login' : '/home');
}
+1 -1
View File
@@ -67,7 +67,7 @@ class _AuthScreenState extends State<AuthScreen> {
),
const SizedBox(height: 4),
Text(
'野生动物实时识别',
'动物实时识别',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600),
),
+16
View File
@@ -2,13 +2,17 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// 登录会话持久化:token + 手机号存 secure storage。
/// 启动时读取判断是否已登录;登出/401 时清除回登录页。
/// 另存「已确认更新版本」:用户点过「立即更新」后记录服务器版本号,
/// 与 APK 内 versionName 取较大者参与更新判断(APK 版本号不递增也不会反复提示)。
class SessionStore {
static const _storage = FlutterSecureStorage();
static const _tokenKey = 'auth_token';
static const _phoneKey = 'auth_phone';
static const _acceptedUpdateKey = 'accepted_update_version';
static String? _cachedToken;
static String? _cachedPhone;
static String? _cachedAcceptedUpdate;
Future<String?> readToken() async {
if (_cachedToken != null) return _cachedToken;
@@ -20,6 +24,18 @@ class SessionStore {
return _cachedPhone = await _storage.read(key: _phoneKey);
}
/// 用户点过「立即更新」的服务器版本号(空串 = 从未接受过更新提示)
Future<String> readAcceptedUpdateVersion() async {
if (_cachedAcceptedUpdate != null) return _cachedAcceptedUpdate!;
return _cachedAcceptedUpdate =
(await _storage.read(key: _acceptedUpdateKey)) ?? '';
}
Future<void> saveAcceptedUpdateVersion(String version) async {
_cachedAcceptedUpdate = version;
await _storage.write(key: _acceptedUpdateKey, value: version);
}
Future<void> save(String phone, String token) async {
_cachedPhone = phone;
_cachedToken = token;
+243 -34
View File
@@ -1,61 +1,257 @@
import 'dart:async';
import 'package:camera/camera.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'frame_analyzer.dart';
/// camera 插件封装:后摄图像流(对应 Kotlin CameraController
class AppCameraController {
/// 相机抽象(对应 Kotlin CameraController
///
/// - Android:自写原生通道([NativeCameraController])。分析帧在 Kotlin 侧
/// 旋转成竖屏后经 EventChannel 回传,Flutter 侧恒 rotation=0——与 iOS
/// camera_avfoundation 插件,帧本来就是竖屏方向)行为一致,消除
/// "横屏传感器帧 → 90° 旋转 + FIT_COVER crop 映射"的标注偏移根因。
/// - iOS:camera 插件(原逻辑)。帧已竖屏,rotation 恒 0。
abstract class AppCameraController {
static Future<AppCameraController?> create() async {
if (defaultTargetPlatform == TargetPlatform.android) {
return NativeCameraController();
}
return PluginCameraController.create();
}
/// 分析流回调实际触发次数(诊断用,与 analyzer 帧计数区分)
int get streamCallbacks;
bool get isInitialized;
bool get isStreaming;
String? get errorDescription;
/// 检测框 overlay 应使用的旋转角。两平台帧都已竖屏 → 恒 0。
int get rotationDegrees => 0;
/// 诊断:传感器方向 / 显示旋转(仅 Android 原生通道上报)
int? get sensorOrientation => null;
int? get displayDegrees => null;
/// 预览实际应用的旋转圈数(仅 Android 原生通道上报,诊断用)
int get quarterTurns => -1;
/// 诊断:轮询原生侧帧状态(Android 返回计数;iOS 返回空)
Future<Map<dynamic, dynamic>> stats() async => const {};
Future<void> start(FrameAnalyzer analyzer);
Future<void> stop();
Future<double> getMinZoomLevel();
Future<double> getMaxZoomLevel();
Future<void> setZoomLevel(double value);
/// 预览 widgetAndroid 为原生 SurfaceViewAndroidView),iOS 为插件纹理
Widget buildPreview();
}
/// Android:自写原生相机通道(Kotlin CameraChannel)。
class NativeCameraController extends AppCameraController {
static const MethodChannel _channel = MethodChannel('observer/camera');
static const EventChannel _frames = EventChannel('observer/camera/frames');
StreamSubscription<dynamic>? _sub;
bool _streaming = false;
String? _error;
/// 原生侧注册的预览纹理(SurfaceTexture
int? _textureId;
int _textureW = 1920;
int _textureH = 1080;
int _quarterTurns = 1;
@override
int streamCallbacks = 0;
int? _sensorOrientation;
int? _displayDegrees;
@override
int? get sensorOrientation => _sensorOrientation;
@override
int? get displayDegrees => _displayDegrees;
@override
int get quarterTurns => _quarterTurns;
@override
bool get isInitialized => _streaming;
@override
bool get isStreaming => _streaming;
@override
String? get errorDescription => _error;
@override
Future<Map<dynamic, dynamic>> stats() async {
try {
final r = await _channel.invokeMethod<Map<dynamic, dynamic>>('stats');
if (r != null) {
// 旋转/显示角度随轮询实时刷新:手机旋转后预览与帧旋转都跟着变
final dd = r['displayDegrees'];
if (dd is int) _displayDegrees = dd;
final turns = r['quarterTurns'];
if (turns is int) _quarterTurns = turns;
}
return r ?? const {};
} catch (e) {
return {'pollErr': '$e'};
}
}
@override
Future<void> start(FrameAnalyzer analyzer) async {
await stop();
// 先订阅再启动:原生 start 绑定后立刻推帧,避免首帧竞态
_sub = _frames.receiveBroadcastStream().listen((event) {
// 计数放最前:任何到达的事件都先记账,后续解析失败也不丢计数
streamCallbacks++;
try {
final f = event as List;
final w = f[0] as int;
final h = f[1] as int;
final rotation = f[2] as int;
final bgra = f[3] as bool;
final bytes = f[4] as Uint8List;
analyzer.analyzeRaw(
planes: [bytes],
strides: [w * 4],
width: w,
height: h,
isBgra: true,
rgbaOrder: !bgra,
rotationDegrees: rotation,
);
} catch (e) {
_error = '帧解析: $e';
analyzer.recordStreamError('frames parse: $e');
}
}, onError: (Object e) {
_error = '$e';
analyzer.recordStreamError('frames: $e');
});
analyzer.reset();
analyzer.worker?.reset();
try {
final r = await _channel.invokeMethod<Map<dynamic, dynamic>>('start');
_textureId = r?['textureId'] as int?;
_textureW = (r?['w'] as num?)?.toInt() ?? _textureW;
_textureH = (r?['h'] as num?)?.toInt() ?? _textureH;
_quarterTurns = (r?['quarterTurns'] as num?)?.toInt() ?? 1;
_sensorOrientation = (r?['sensorOrientation'] as num?)?.toInt();
_displayDegrees = (r?['displayDegrees'] as num?)?.toInt();
} catch (e) {
_error = '$e';
analyzer.recordStreamError('camera start: $e');
rethrow;
}
_streaming = true;
}
@override
Future<void> stop() async {
_streaming = false;
await _sub?.cancel();
_sub = null;
try {
await _channel.invokeMethod('stop');
} catch (_) {}
}
@override
Future<double> getMinZoomLevel() async {
final r = await _channel.invokeMethod<List<dynamic>>('getZoomRange');
return (r != null && r.isNotEmpty ? (r[0] as num).toDouble() : 1.0);
}
@override
Future<double> getMaxZoomLevel() async {
final r = await _channel.invokeMethod<List<dynamic>>('getZoomRange');
return (r != null && r.length > 1 ? (r[1] as num).toDouble() : 1.0);
}
@override
Future<void> setZoomLevel(double value) async {
try {
await _channel.invokeMethod('setZoom', value);
} catch (_) {}
}
/// 预览纹理:Flutter 引擎渲染 Texture 时已自动应用 SurfaceTexture 变换矩阵
/// (传感器方向补偿,内容已转成自然方向的竖屏),因此:
/// 1. 区域声明旋转后的尺寸(宽高互换)——否则横屏区域会把竖屏内容横向拉伸
/// 2. RotatedBox 只按显示旋转补偿(quarterTurns = 屏转/90,竖屏 0 / 横屏 1)
/// 再 FittedBox cover= CoordinateMapper 的 FIT_COVER 数学一致)填满全屏。
/// 不用 AndroidView+SurfaceView——平台视图会盖住 Flutter UI(诊断行/设置按钮/overlay
@override
Widget buildPreview() {
final id = _textureId;
if (id == null) return const SizedBox.shrink();
return FittedBox(
fit: BoxFit.cover,
child: RotatedBox(
quarterTurns: _quarterTurns % 4,
child: SizedBox(
width: _textureH.toDouble(),
height: _textureW.toDouble(),
child: Texture(textureId: id),
),
),
);
}
}
/// iOScamera 插件封装(原实现)。
class PluginCameraController extends AppCameraController {
final List<CameraDescription> cameras;
CameraController? controller;
/// 图像流回调实际触发次数(诊断用,与 analyzer 帧计数区分)
@override
int streamCallbacks = 0;
AppCameraController._(this.cameras);
PluginCameraController._(this.cameras);
static Future<AppCameraController?> create() async {
static Future<PluginCameraController?> create() async {
final cameras = await availableCameras();
if (cameras.isEmpty) return null;
return AppCameraController._(cameras);
return PluginCameraController._(cameras);
}
@override
bool get isInitialized => controller?.value.isInitialized ?? false;
CameraController get currentController =>
controller ?? (throw StateError('camera not initialized'));
@override
bool get isStreaming => controller?.value.isStreamingImages ?? false;
/// 图像流送达时的旋转角(传感器 → 竖屏显示所需的顺时针旋转)。
/// 与 CameraX rotationDegrees 同公式;预览本身由平台旋转,检测框 overlay
/// 用同一角度映射即可对齐。
int get rotationDegrees {
final c = controller;
if (c == null) return 0;
final deviceDegrees = switch (c.value.deviceOrientation) {
DeviceOrientation.portraitUp => 0,
DeviceOrientation.landscapeLeft => 90,
DeviceOrientation.portraitDown => 180,
DeviceOrientation.landscapeRight => 270,
};
final sensor = c.description.sensorOrientation;
final isFront =
c.description.lensDirection == CameraLensDirection.front;
final degrees = (isFront ? sensor + deviceDegrees : sensor - deviceDegrees) % 360;
return degrees < 0 ? degrees + 360 : degrees;
}
@override
String? get errorDescription => controller?.value.errorDescription;
@override
Future<void> start(FrameAnalyzer analyzer) async {
await stop();
final desc = cameras.firstWhere(
(c) => c.lensDirection == CameraLensDirection.back,
orElse: () => cameras.first);
// iOS 用默认 bgra8888420v 在部分 iOS 版本上视频输出静默不送帧),
// Android 用 yuv420 多平面。
final fmt = defaultTargetPlatform == TargetPlatform.iOS
? ImageFormatGroup.bgra8888
: ImageFormatGroup.yuv420;
final c = CameraController(desc, ResolutionPreset.high,
enableAudio: false, imageFormatGroup: fmt);
// iOS image stream 帧已按竖屏方向输出(无需旋转),
// Android 原生通道(帧原生侧旋转成竖屏)统一 rotation=0
final c = CameraController(desc, ResolutionPreset.veryHigh,
enableAudio: false, imageFormatGroup: ImageFormatGroup.bgra8888);
controller = c;
await c.initialize();
// 相机(重新)启动后重置运动/背景参考与抽帧节流,避免旧场景残留
@@ -66,7 +262,7 @@ class AppCameraController {
await c.startImageStream((image) {
streamCallbacks++;
try {
analyzer.analyze(image, rotationDegrees);
analyzer.analyze(image, 0);
} catch (e, st) {
debugPrint('[camera] analyze error: $e\n$st');
analyzer.recordStreamError('analyze: $e');
@@ -80,6 +276,7 @@ class AppCameraController {
}
}
@override
Future<void> stop() async {
final c = controller;
if (c == null) return;
@@ -89,4 +286,16 @@ class AppCameraController {
} catch (_) {}
await c.dispose();
}
@override
Future<double> getMinZoomLevel() => controller!.getMinZoomLevel();
@override
Future<double> getMaxZoomLevel() => controller!.getMaxZoomLevel();
@override
Future<void> setZoomLevel(double value) => controller!.setZoomLevel(value);
@override
Widget buildPreview() => CameraPreview(controller!);
}
+154 -41
View File
@@ -1,12 +1,12 @@
import 'dart:ui' show PlatformDispatcher;
import 'dart:async';
import 'dart:ui' as ui show PlatformDispatcher;
import 'package:camera/camera.dart';
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../detection/detector_worker.dart';
import '../models/model_manager.dart';
import '../reminder/reminder.dart';
import 'app_camera_controller.dart';
import 'camera_view_model.dart';
@@ -30,17 +30,88 @@ class _CameraScreenState extends State<CameraScreen> {
String? _globalError;
String? _initError;
/// 置信度阈值(设置页滑块调整,worker 内实时生效)
double _minScore = 0.10;
/// 原生侧帧状态轮询结果(诊断用;无帧时诊断行也能实时刷新)
Map<dynamic, dynamic> _nativeStats = const {};
Timer? _statsTimer;
void _openSettings() {
final vm = _viewModel;
if (vm == null) return;
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.black87,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSheetState) => Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('识别设置',
style: TextStyle(
color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
Row(
children: [
const Text('置信度阈值',
style: TextStyle(color: Colors.white70, fontSize: 14)),
const Spacer(),
Text('${(_minScore * 100).toStringAsFixed(0)}%',
style: const TextStyle(
color: Colors.greenAccent,
fontSize: 14,
fontWeight: FontWeight.bold)),
],
),
Slider(
value: _minScore,
min: 0.05,
max: 0.50,
divisions: 45,
activeColor: Colors.greenAccent,
onChanged: (v) {
setSheetState(() => _minScore = v);
_analyzer?.worker?.setMinScore(v);
},
),
const SizedBox(height: 8),
const Text(
'阈值越低识别越灵敏(低分框越多,误报也可能增加);'
'野鸡模型置信度普遍在 10%~20%,场景识别不到时可适当调低。',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
),
),
);
}
@override
void initState() {
super.initState();
final oldPlatform = PlatformDispatcher.instance.onError;
PlatformDispatcher.instance.onError = (error, stack) {
setState(() => _globalError = 'Platform: $error');
final oldPlatform = ui.PlatformDispatcher.instance.onError;
ui.PlatformDispatcher.instance.onError = (error, stack) {
setState(() => _globalError =
'Platform: $error\n${stack.toString().split('\n').take(3).join('\n')}');
return oldPlatform?.call(error, stack) ?? false;
};
WidgetsBinding.instance.addPostFrameCallback((_) => _init());
// 相机页常亮:野外观察时保持屏幕不熄(离开页面时关闭)
WakelockPlus.enable();
// 每秒轮询原生侧帧状态:无帧时诊断行也能实时刷新(camErr/计数)
_statsTimer = Timer.periodic(const Duration(seconds: 1), (_) => _pollStats());
}
Future<void> _pollStats() async {
final camera = _cameraController;
if (camera == null) return;
final s = await camera.stats();
if (!mounted) return;
setState(() => _nativeStats = s);
}
Future<void> _init() async {
@@ -49,8 +120,18 @@ class _CameraScreenState extends State<CameraScreen> {
setState(() => _permissionGranted = granted);
if (!granted) return;
// 模型热更新:优先使用已下载的数据集模型(启动时后台拉取;此处兜底等待,
// 下载慢/失败不阻塞相机启动——无下载模型时 worker 回退内置资产)
if (!ModelManager.instance.ready) {
try {
await ModelManager.instance
.refresh()
.timeout(const Duration(seconds: 15));
} catch (_) {}
}
// 模型加载/推理在后台 isolate,不阻塞 UIworker 为 null 时仅预览并提示
final worker = await DetectorWorker.create();
final worker = await DetectorWorker.create(
models: ModelManager.instance.models);
final viewModel = CameraViewModel(reminder: Reminder());
viewModel.setModelReady(worker != null);
final analyzer = FrameAnalyzer(worker: worker, viewModel: viewModel);
@@ -100,6 +181,7 @@ class _CameraScreenState extends State<CameraScreen> {
@override
void dispose() {
_statsTimer?.cancel();
WakelockPlus.disable();
_cameraController?.stop();
_analyzer?.dispose();
@@ -120,29 +202,24 @@ class _CameraScreenState extends State<CameraScreen> {
if (!_permissionGranted)
_PermissionGuide(onRequest: () => _init())
else if (vm != null && (camera?.isInitialized ?? false))
// 预览 + 检测框同几何:overlay 作为 CameraPreview 的 child
// 与纹理共享同一 Stack/尺寸,避免比例或裁剪导致的位置偏移
// 预览 + 检测框同几何:overlay 作为预览 widget 的 sibling
// 与纹理共享同一 Stack/尺寸,避免比例或裁剪导致的位置偏移
// 两平台分析帧都已竖屏(Android 原生侧旋转 / iOS 插件本来就竖屏),
// rotation 恒 0CoordinateMapper 走纯 FIT_COVER 缩放路径
ListenableBuilder(
listenable: vm,
builder: (context, _) => _ZoomablePreview(
controller: camera!.currentController,
imageWidthPx: vm.state.imageWidthPx,
imageHeightPx: vm.state.imageHeightPx,
controller: camera!,
overlay: DetectionOverlay(
results: vm.state.results,
// iOS 纹理不旋转显示(_wrapInRotatedBox 仅 Android),
// 显示方向 = buffer 原样 = 检测方向,旋转必须为 0;
// Android 纹理被 RotatedBox 旋转,需用插件报告的 rotation。
rotation: defaultTargetPlatform == TargetPlatform.iOS
? 0
: vm.state.rotation,
rotation: 0,
imageWidthPx: vm.state.imageWidthPx,
imageHeightPx: vm.state.imageHeightPx,
),
),
)
else if (camera?.isInitialized ?? false)
_ZoomablePreview(controller: camera!.currentController)
_ZoomablePreview(controller: camera!)
else
const Center(
child: Text('相机启动中…', style: TextStyle(color: Colors.white70)),
@@ -197,6 +274,7 @@ class _CameraScreenState extends State<CameraScreen> {
right: 0,
child: _CameraTopBar(
onClose: () => Navigator.of(context).pop(),
onOpenSettings: _openSettings,
),
),
],
@@ -209,6 +287,27 @@ class _CameraScreenState extends State<CameraScreen> {
return Stack(
fit: StackFit.expand,
children: [
// 模型热更新下载失败提示(已加载模型仍可用,仅提示补更新)
if (vm.state.modelReady && ModelManager.instance.error != null)
Positioned(
left: 16,
right: 16,
top: MediaQuery.of(context).padding.top + 56,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: Text(
ModelManager.instance.error!,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.orange, fontSize: 12),
),
),
),
// 模型未加载时仅显示相机预览,不做检测标注(横幅置于顶栏下方,避免与底部诊断行重叠)
if (!vm.state.modelReady)
Positioned(
@@ -238,13 +337,25 @@ class _CameraScreenState extends State<CameraScreen> {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'模型:${vm.state.modelReady ? '已加载' : '未加载'} 帧:${vm.state.framesReceived} 流:${camera?.streamCallbacks ?? 0} 推理:${vm.state.debugDetectCalls}次 异常:${vm.state.debugDetectErrors}次 处理:${vm.state.debugLastMs}ms 最高分:${(vm.state.debugHighestScore * 100).toStringAsFixed(1)}% 图:${vm.state.imageWidthPx}x${vm.state.imageHeightPx} 旋:${vm.state.rotation}',
'阈值:${(_minScore * 100).toStringAsFixed(0)}% 模型:${vm.state.modelReady ? ModelManager.instance.modelsLabel : '未加载'} 帧:${vm.state.framesReceived} 流:${camera?.streamCallbacks ?? 0} 推理:${vm.state.debugDetectCalls}次 异常:${vm.state.debugDetectErrors}次 处理:${vm.state.debugLastMs}ms 最高分:${(vm.state.debugHighestScore * 100).toStringAsFixed(1)}% 图:${vm.state.imageWidthPx}x${vm.state.imageHeightPx} 传感:${camera?.sensorOrientation ?? '-'} 屏转:${camera?.displayDegrees ?? '-'} 旋:${camera?.rotationDegrees ?? 0} turn:${camera?.quarterTurns ?? '-'}',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
if (_nativeStats.isNotEmpty)
Text(
'原生:回调${_nativeStats['callbacks'] ?? '-'} 发出${_nativeStats['emitOk'] ?? '-'} 异常${_nativeStats['emitErr'] ?? '-'} 无订阅${_nativeStats['sinkNull'] ?? '-'} sink:${_nativeStats['sink'] ?? '-'} 配置:${_nativeStats['size'] ?? '-'} 发帧:${_nativeStats['emitSize'] ?? '-'} 错误:${_nativeStats['error'] ?? ''} 轮询:${_nativeStats['pollErr'] ?? 'ok'}${vm.state.results.isEmpty ? '' : ' 框1:(${vm.state.results.first.left.toStringAsFixed(2)},${vm.state.results.first.top.toStringAsFixed(2)},${vm.state.results.first.right.toStringAsFixed(2)},${vm.state.results.first.bottom.toStringAsFixed(2)})'}',
style: const TextStyle(
color: Colors.amberAccent, fontSize: 11),
),
if (vm.state.debugYuv.isNotEmpty)
Text(
'yuv:${vm.state.debugYuv}',
style: const TextStyle(
color: Colors.yellowAccent, fontSize: 11),
),
if (camera != null)
Text(
'streaming:${camera.currentController.value.isStreamingImages} '
'camErr:${camera.currentController.value.errorDescription ?? ''}',
'streaming:${camera.isStreaming} '
'camErr:${camera.errorDescription ?? ''}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
@@ -274,24 +385,19 @@ class _CameraScreenState extends State<CameraScreen> {
],
);
}
}
/// 双指捏合缩放预览;overlay 与纹理同几何(CameraPreview child
/// 双指捏合缩放预览;overlay 与纹理同几何(Stack 内同尺寸
class _ZoomablePreview extends StatefulWidget {
final CameraController controller;
final AppCameraController controller;
/// 检测框 overlay(随帧更新,作为 CameraPreview 的 child 与纹理同区域)
/// 检测框 overlay(随帧更新,与纹理同区域)
final Widget? overlay;
/// 当前帧图像尺寸(用于按 buffer 比例约束预览,保证无拉伸变形)
final int imageWidthPx;
final int imageHeightPx;
const _ZoomablePreview({
required this.controller,
this.overlay,
this.imageWidthPx = 0,
this.imageHeightPx = 0,
});
@override
@@ -317,7 +423,9 @@ class _ZoomablePreviewState extends State<_ZoomablePreview> {
@override
Widget build(BuildContext context) {
final preview = GestureDetector(
// 预览铺满全屏(cover 裁剪由插件按视图比例完成);
// overlay 与纹理同几何:作为 Stack sibling 叠在上层,坐标与纹理区域一致
return GestureDetector(
onScaleStart: (_) => _gestureStartZoom = _currentZoom,
onScaleUpdate: (d) {
final target =
@@ -326,24 +434,24 @@ class _ZoomablePreviewState extends State<_ZoomablePreview> {
_currentZoom = target;
widget.controller.setZoomLevel(target);
},
child: CameraPreview(widget.controller, child: widget.overlay),
);
final w = widget.imageWidthPx.toDouble();
final h = widget.imageHeightPx.toDouble();
if (w <= 0 || h <= 0) return preview;
// 按 buffer 比例约束显示区域:纹理与 overlay 同区域等比显示(无变形)
return Center(
child: AspectRatio(aspectRatio: w / h, child: preview),
child: Stack(
fit: StackFit.expand,
children: [
widget.controller.buildPreview(),
if (widget.overlay != null) widget.overlay!,
],
),
);
}
}
class _CameraTopBar extends StatelessWidget {
final VoidCallback onClose;
final VoidCallback onOpenSettings;
const _CameraTopBar({
required this.onClose,
required this.onOpenSettings,
});
@override
@@ -359,6 +467,11 @@ class _CameraTopBar extends StatelessWidget {
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: onClose,
),
IconButton(
tooltip: '识别设置',
icon: const Icon(Icons.tune, color: Colors.white70),
onPressed: onOpenSettings,
),
],
),
);
@@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart';
import '../detection/detection_result.dart';
import '../detection/motion_aggregator.dart';
import '../detection/tflite_detector.dart';
import '../reminder/reminder.dart';
@immutable
@@ -20,6 +19,7 @@ class CameraUiState {
final String? debugLastError;
final int framesReceived;
final int debugLastMs;
final String debugYuv;
const CameraUiState({
this.modelReady = false,
@@ -33,20 +33,19 @@ class CameraUiState {
this.debugLastError,
this.framesReceived = 0,
this.debugLastMs = 0,
this.debugYuv = '',
});
}
/// 检测结果置信度分级与轨迹确认。
///
/// - [lowConf](模型阈值 0.10):低于此分的框在检测阶段已丢弃。
/// - [highConf]0.35):高于此分直接确认显示;真实野鸡多为 0.1~0.2,
/// 高于 0.35 视为强证据。
/// - 0.10~0.35 之间:需要多帧稳定([confirmFrames] 帧)或 活动证据
/// - 低于 0.35 的框:需要多帧稳定([confirmFrames] 帧)或 活动证据
/// (运动区域/背景新出现区域重叠)才确认显示。
class CameraViewModel extends ChangeNotifier {
static const int maxTracks = 30;
static const double motionBoost = 0.15;
static const double lowConf = TfliteDetector.minScore;
static const double highConf = 0.35;
static const int confirmFrames = 3;
static const double associateRadius = 0.12;
@@ -86,6 +85,7 @@ class CameraViewModel extends ChangeNotifier {
String? lastError,
int framesReceived = 0,
int lastProcessMs = 0,
String yuvDiag = '',
}) {
final now = DateTime.now().millisecondsSinceEpoch;
_associate(results, motionRegions, noveltyRegions, now);
@@ -132,6 +132,7 @@ class CameraViewModel extends ChangeNotifier {
debugLastError: lastError,
framesReceived: framesReceived,
debugLastMs: lastProcessMs,
debugYuv: yuvDiag.isNotEmpty ? yuvDiag : _state.debugYuv,
);
notifyListeners();
}
@@ -87,10 +87,13 @@ class _OverlayPainter extends CustomPainter {
_drawDashedRect(canvas, box, paint);
}
// 标签:框上方,含距离
// 标签:框上方,含距离;多模型时标注来源模型名(内置资产不标)
final dist = _distanceLabel(r);
final modelTag = r.modelName.isNotEmpty && r.modelName != '内置'
? '[${r.modelName}]'
: '';
final text =
'${_labels[r.label] ?? r.label} ${(r.score * 100).toInt()}%$dist';
'${_labels[r.label] ?? r.label}$modelTag ${(r.score * 100).toInt()}%$dist';
final textPainter = TextPainter(
text: TextSpan(
text: text,
+40 -6
View File
@@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:camera/camera.dart';
import '../detection/detection_result.dart';
@@ -44,7 +46,8 @@ class FrameAnalyzer {
int rotation,
int width,
int height,
int processMs) {
int processMs,
String yuvDiag) {
detectCalls++;
lastProcessMs = processMs;
viewModel.onFramesAnalyzed(
@@ -59,6 +62,7 @@ class FrameAnalyzer {
lastError: lastError,
framesReceived: framesReceived,
lastProcessMs: lastProcessMs,
yuvDiag: yuvDiag,
);
}
@@ -80,15 +84,45 @@ class FrameAnalyzer {
);
}
void analyze(CameraImage image, int rotationDegrees) {
/// 节流与 busy 丢帧判定(两入口共用);通过后才允许投递
bool _canSend() {
framesReceived++;
final w = worker;
if (w == null) return;
if (w == null) return false;
final now = DateTime.now().millisecondsSinceEpoch;
if (now - _lastDetectMs < intervalMs) return;
if (now - _lastDetectMs < intervalMs) return false;
_lastDetectMs = now;
if (w.busy) return; // 上一帧未返回则丢帧,避免在途积压
w.analyze(image, rotationDegrees);
if (w.busy) return false; // 上一帧未返回则丢帧,避免在途积压
return true;
}
void analyze(CameraImage image, int rotationDegrees,
{bool rgbaOrder = false}) {
if (!_canSend()) return;
worker!.analyze(image, rotationDegrees, rgbaOrder: rgbaOrder);
}
/// 原生相机通道帧(Android):字节已在 Kotlin 侧旋转成竖屏,rotation=0。
/// isBgra=true + rgbaOrder 与插件路径同语义:false=BGRA(rOff=2)/true=RGBA(rOff=0)
void analyzeRaw({
required List<Uint8List> planes,
required List<int> strides,
required int width,
required int height,
required bool isBgra,
required bool rgbaOrder,
int rotationDegrees = 0,
}) {
if (!_canSend()) return;
worker!.analyzeRaw(
planes: planes,
strides: strides,
width: width,
height: height,
isBgra: isBgra,
rgbaOrder: rgbaOrder,
rotationDegrees: rotationDegrees,
);
}
void reset() => _lastDetectMs = 0;
@@ -12,7 +12,7 @@ class ViewRect {
double get centerY => (top + bottom) / 2;
}
/// 模型归一化坐标 → 预览视图坐标(含传感器旋转与 FIT_CENTER 裁剪)。
/// 模型归一化坐标 → 预览视图坐标(含传感器旋转与 FIT_COVER 全屏裁剪)。
class CoordinateMapper {
static ViewRect mapToView(
double normLeft,
@@ -53,10 +53,10 @@ class CoordinateMapper {
final portrait = rotation == 90 || rotation == 270;
final portW = portrait ? imageH : imageW;
final portH = portrait ? imageW : imageH;
// 3) FIT_CENTER 缩放与居中偏移
// 3) FIT_COVER 缩放与居中裁剪:放大到铺满视图,溢出部分裁掉
final scale = viewW / portW < viewH / portH
? viewW / portW
: viewH / portH;
? viewH / portH
: viewW / portW;
final offsetX = (viewW - portW * scale) / 2;
final offsetY = (viewH - portH * scale) / 2;
return ViewRect(
@@ -9,6 +9,10 @@ class DetectionResult {
/// 轨迹已确认(多帧稳定/高分/活动确认),false = 候选,渲染为虚线
final bool confirmed;
/// 产出该框的模型(数据集 id 与名称;内置资产模型为 -1/空)
final int modelId;
final String modelName;
const DetectionResult({
required this.label,
required this.score,
@@ -17,6 +21,8 @@ class DetectionResult {
required this.right,
required this.bottom,
this.confirmed = true,
this.modelId = -1,
this.modelName = '',
});
double get width => right - left;
@@ -40,6 +46,8 @@ class DetectionResult {
right: right ?? this.right,
bottom: bottom ?? this.bottom,
confirmed: confirmed ?? this.confirmed,
modelId: modelId,
modelName: modelName,
);
}
+273 -41
View File
@@ -7,17 +7,27 @@ import 'package:flutter/foundation.dart' show debugPrint;
import 'package:flutter/services.dart' show rootBundle;
import '../camera/motion_detector.dart';
import '../models/model_manager.dart';
import 'background_model.dart';
import 'detection_result.dart';
import 'nms.dart';
import 'tflite_detector.dart';
import 'visual_prior.dart';
/// 推理工作单元:模型加载与检测全部在后台 isolate 执行,
/// 主 isolate 只投递帧数据、接收结果,UI 不被推理阻塞(iOS 真机卡顿根因)。
///
/// 多模型并行推理:传入 [models](各数据集下载模型)后,每帧逐模型推理,
/// 结果按类别分组跨模型 NMS 合并(同标签重复框取高分,不同标签互不压制);
/// 无下载模型时回退内置资产模型。
class DetectorWorker {
static const String modelAsset = 'assets/model.tflite';
static const String labelsAsset = 'assets/labels.txt';
/// 内置资产回退模型的标识
static const int builtinModelId = -1;
static const String builtinModelName = '内置';
final Isolate _isolate;
final ReceivePort _responses;
@@ -30,9 +40,10 @@ class DetectorWorker {
int _inFlight = 0;
bool _dead = false;
/// 结果回调:结果 / 运动区域 / 新颖区域 / 旋转角 / 图宽 / 图高 / 处理耗时 ms
/// 结果回调:结果 / 运动区域 / 新颖区域 / 旋转角 / 图宽 / 图高 /
/// 处理耗时 ms / yuv 决策诊断串
void Function(List<DetectionResult>, List<MotionRegion>, List<MotionRegion>,
int, int, int, int)? onResult;
int, int, int, int, String)? onResult;
/// 单帧处理异常回调(不影响相机流)
void Function(String)? onError;
@@ -53,16 +64,26 @@ class DetectorWorker {
});
}
/// 读取模型资产并启动后台推理 isolate;加载失败返回 null(App 降级为仅预览)。
static Future<DetectorWorker?> create() async {
/// 加载模型并启动后台推理 isolate;加载失败返回 null(App 降级为仅预览)。
/// [models] 为空时回退内置资产模型(模型缺失同样返回 null)。
static Future<DetectorWorker?> create({List<ModelBundle>? models}) async {
try {
final data = await rootBundle.load(modelAsset);
final modelBytes =
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
final labels = (await rootBundle.loadString(labelsAsset))
.split('\n')
.where((l) => l.trim().isNotEmpty)
.toList();
final payload = <List<Object?>>[];
if (models != null && models.isNotEmpty) {
for (final m in models) {
payload.add(
[m.bytes, m.labels, m.datasetId, m.datasetName]);
}
} else {
final data = await rootBundle.load(modelAsset);
final modelBytes =
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
final labels = (await rootBundle.loadString(labelsAsset))
.split('\n')
.where((l) => l.trim().isNotEmpty)
.toList();
payload.add([modelBytes, labels, builtinModelId, builtinModelName]);
}
final responses = ReceivePort();
final isolate = await Isolate.spawn(_workerMain, responses.sendPort);
@@ -72,7 +93,7 @@ class DetectorWorker {
.timeout(const Duration(seconds: 10),
onTimeout: () => throw TimeoutException('worker port timeout'));
worker._port = port;
port.send(['load', modelBytes, labels]);
port.send(['load', payload]);
await worker._ready.future
.timeout(const Duration(seconds: 20), onTimeout: () {
throw TimeoutException('model load timeout');
@@ -88,20 +109,37 @@ class DetectorWorker {
/// 是否忙(上一帧尚未返回):忙则丢帧,避免在途积压
bool get busy => _inFlight > 0;
void analyze(CameraImage image, int rotationDegrees) {
void analyze(CameraImage image, int rotationDegrees, {bool rgbaOrder = false}) {
// 单平面 8888 判定:仅明确的 yuv420/nv21 走多平面 YUV 路径;
// bgra8888 与 unknown(插件未识别 RGBA_8888 输出时)都按 4 字节像素处理
final group = image.format.group;
analyzeRaw(
planes: image.planes.map((p) => p.bytes).toList(),
strides: image.planes.map((p) => p.bytesPerRow).toList(),
width: image.width,
height: image.height,
isBgra: group != ImageFormatGroup.yuv420 && group != ImageFormatGroup.nv21,
rgbaOrder: rgbaOrder,
rotationDegrees: rotationDegrees,
);
}
/// 原始字节帧投递(截屏注入用:toImage 的 RGBA 字节直接进检测,不经 CameraImage
void analyzeRaw({
required List<Uint8List> planes,
required List<int> strides,
required int width,
required int height,
required bool isBgra,
required bool rgbaOrder,
required int rotationDegrees,
}) {
final port = _port;
if (port == null || _dead) return;
_inFlight++;
port.send([
'frame',
[
image.planes.map((p) => p.bytes).toList(),
image.planes.map((p) => p.bytesPerRow).toList(),
image.width,
image.height,
image.format.group == ImageFormatGroup.bgra8888,
rotationDegrees,
],
[planes, strides, width, height, isBgra, rotationDegrees, rgbaOrder],
]);
}
@@ -129,6 +167,8 @@ class DetectorWorker {
top: v[3] as double,
right: v[4] as double,
bottom: v[5] as double,
modelId: v.length > 6 ? (v[6] as num).toInt() : -1,
modelName: v.length > 7 ? v[7] as String : '',
);
}).toList();
final motion = (list[5] as List)
@@ -142,7 +182,7 @@ class DetectorWorker {
v[0] as double, v[1] as double, v[2] as double, v[3] as double))
.toList();
onResult?.call(dets, motion, novelty, list[1] as int, list[2] as int,
list[3] as int, list[7] as int);
list[3] as int, list[7] as int, list[8] as String);
break;
case 'log':
lastLog = list[1] as String;
@@ -151,6 +191,7 @@ class DetectorWorker {
case 'error':
_inFlight--;
onError?.call(list[1] as String);
break;
}
}
@@ -161,6 +202,13 @@ class DetectorWorker {
port.send(['reset']);
}
/// 调整置信度阈值(设置页滑块,worker 内实时生效)
void setMinScore(double v) {
final port = _port;
if (port == null || _dead) return;
port.send(['set-min-score', v]);
}
void dispose() {
_dead = true;
_isolate.kill(priority: Isolate.immediate);
@@ -176,9 +224,11 @@ Future<void> _workerMain(SendPort mainPort) async {
mainPort.send(['port', control.sendPort]);
mainPort.send(['log', 'worker-start']);
TfliteDetector? detector;
List<TfliteDetector> detectors = const [];
MotionDetector? motion;
BackgroundModel? background;
var lastDualMs = 0; // 双字节序推理诊断节流
Uint8List? prevY; // 上一帧 Y/RGBA 平面(帧间 diff 诊断)
await for (final msg in control) {
try {
final list = msg as List;
@@ -186,12 +236,35 @@ Future<void> _workerMain(SendPort mainPort) async {
case 'load':
mainPort.send(['log', 'load-received']);
try {
detector = await TfliteDetector.fromBuffer(
list[1] as Uint8List, (list[2] as List).cast<String>());
if (detector == null) {
mainPort.send(['load-error', 'fromBuffer 返回 null']);
// 多模型:逐模型加载,单个失败不阻塞其余;全部失败才报错
final loaded = <TfliteDetector>[];
final failures = <String>[];
for (final entry in list[1] as List) {
final e = entry as List;
final name = e.length > 3 ? e[3] as String : '';
final d = await TfliteDetector.fromBuffer(
e[0] as Uint8List,
(e[1] as List).cast<String>(),
modelId: (e[2] as num).toInt(),
modelName: name,
);
if (d == null) {
failures.add(name.isEmpty ? 'unknown' : name);
} else {
loaded.add(d);
}
}
if (loaded.isEmpty) {
mainPort.send([
'load-error',
'模型加载失败:${failures.join(',')} '
'(fromBuffer 返回 null)'
]);
} else {
mainPort.send(['log', 'fromBuffer-ok']);
detectors = loaded;
mainPort.send(['log',
'loaded=${loaded.map((d) => d.modelName).join(',')} '
'failed=${failures.isEmpty ? '-' : failures.join(',')}']);
motion = MotionDetector();
background = BackgroundModel();
mainPort.send(['ready']);
@@ -201,10 +274,9 @@ Future<void> _workerMain(SendPort mainPort) async {
}
break;
case 'frame':
final d = detector;
final m = motion;
final b = background;
if (d == null || m == null || b == null) break;
if (detectors.isEmpty || m == null || b == null) break;
final frame = list[1] as List;
final planes = (frame[0] as List).cast<Uint8List>();
final strides = (frame[1] as List).cast<int>();
@@ -212,15 +284,111 @@ Future<void> _workerMain(SendPort mainPort) async {
final height = frame[3] as int;
final isBgra = frame[4] as bool;
final rotation = frame[5] as int;
final rgbaOrder = frame.length > 6 && (frame[6] as bool);
// 止血:非法帧(宽高/平面为空)直接丢弃并上报诊断,
// 避免下游组件越界(RGBA patch 后插件偶发 w/h=0 帧)
if (width <= 0 || height <= 0 || planes.isEmpty || planes[0].isEmpty) {
mainPort.send([
'error',
'bad frame w=$width h=$height planes=${planes.length} '
'p0=${planes.isNotEmpty ? planes[0].length : 0} '
'stride=${strides.isNotEmpty ? strides[0] : '-'} '
'bgra=$isBgra'
]);
break;
}
final sw = Stopwatch()..start();
var results = d.detectRaw(
planes: planes,
strides: strides,
width: width,
height: height,
isBgra: isBgra,
);
// 帧内容统计(诊断):Y/RGBA 平面 min/max/mean + 与上帧的平均绝对差。
// 均匀灰帧 → min≈max≈mean;静止灰帧 → diff≈0;真实画面 → 分布宽且 diff>0
final yPlane = planes[0];
var yMin = 255, yMax = 0, ySum = 0, diff = 0, sampled = 0;
final prev = prevY;
for (var i = 0; i < yPlane.length; i += 8) {
final v = yPlane[i];
if (v < yMin) yMin = v;
if (v > yMax) yMax = v;
ySum += v;
if (prev != null && i < prev.length) {
final d = v - prev[i];
diff += d < 0 ? -d : d;
}
sampled++;
}
prevY = yPlane;
final yMean = ySum / sampled;
final yDiff =
prev == null ? -1.0 : diff / (sampled * 255.0);
// UV 平面统计(诊断):色序/值域异常会导致解码偏色
var uv1 = '-';
if (planes.length > 1) {
final u = planes[1];
var uMin = 255, uMax = 0, uSum = 0, n = 0;
for (var i = 0; i < u.length; i += 8) {
final v = u[i];
if (v < uMin) uMin = v;
if (v > uMax) uMax = v;
uSum += v;
n++;
}
uv1 = 's=${strides[1]} min=$uMin max=$uMax mean=${(uSum / n).toStringAsFixed(0)}';
if (planes.length > 2) {
final v2 = planes[2];
var vMin = 255, vMax = 0, vSum = 0, n2 = 0;
for (var i = 0; i < v2.length; i += 8) {
final v = v2[i];
if (v < vMin) vMin = v;
if (v > vMax) vMax = v;
vSum += v;
n2++;
}
uv1 += ' v:min=$vMin max=$vMax mean=${(vSum / n2).toStringAsFixed(0)}';
}
}
// 多模型并行推理:每模型先首帧自适应判定 YUV 值域/色序,再逐模型推理;
// 汇总后按类别分组跨模型 NMS 合并(同标签重复框取高分,异标签互不压制)
var results = <DetectionResult>[];
for (final d in detectors) {
if (!isBgra && !d.yuvModeKnown) {
d.decideYuvChroma(
planes: planes,
strides: strides,
width: width,
height: height,
);
}
var dets = d.detectRaw(
planes: planes,
strides: strides,
width: width,
height: height,
isBgra: isBgra,
rgbaOrder: rgbaOrder,
);
// 自愈:判定后 1.5s 内无检测且帧可用 → 用实时帧重跑完整判定
// (首帧模糊/暗帧导致启发式猜错时,画面稳定后 oracle 可分胜负)
if (!isBgra && d.yuvModeKnown && !d.yuvRetried &&
dets.length <= 1 &&
DateTime.now().millisecondsSinceEpoch - d.yuvDecisionMs >
1500 &&
d.retryDecision(
planes: planes,
strides: strides,
width: width,
height: height,
)) {
dets = d.detectRaw(
planes: planes,
strides: strides,
width: width,
height: height,
isBgra: isBgra,
);
}
results.addAll(dets);
}
results = mergeAcrossModels(results, TfliteDetector.iouThreshold);
// 低分野鸡框过视觉先验(颜色/位置),减少户外误报
results = VisualPrior.filter(
results,
@@ -229,6 +397,7 @@ Future<void> _workerMain(SendPort mainPort) async {
width: width,
height: height,
isBgra: isBgra,
rgbaOrder: rgbaOrder,
);
final motionRegions = m.detectMotionRaw(
planes[0], strides[0], width, height);
@@ -236,14 +405,44 @@ Future<void> _workerMain(SendPort mainPort) async {
b.updateRaw(planes[0], strides[0], width, height);
sw.stop();
var dualDiag = '';
if (isBgra &&
DateTime.now().millisecondsSinceEpoch - lastDualMs > 3000) {
lastDualMs = DateTime.now().millisecondsSinceEpoch;
final a = detectors.first.diagnoseOrder(
planes: planes,
strides: strides,
width: width,
height: height,
isBgra: true,
rgbaOrder: false);
final b = detectors.first.diagnoseOrder(
planes: planes,
strides: strides,
width: width,
height: height,
isBgra: true,
rgbaOrder: true);
dualDiag = ' | dual BGRA:${a.$1}@${(a.$2 * 100).toStringAsFixed(1)}%'
' RGBA:${b.$1}@${(b.$2 * 100).toStringAsFixed(1)}%';
}
mainPort.send([
'result',
rotation,
width,
height,
results
.map((r) =>
[r.label, r.score, r.left, r.top, r.right, r.bottom])
.map((r) => [
r.label,
r.score,
r.left,
r.top,
r.right,
r.bottom,
r.modelId,
r.modelName,
])
.toList(),
motionRegions
.map((mr) => [mr.left, mr.top, mr.right, mr.bottom])
@@ -252,14 +451,47 @@ Future<void> _workerMain(SendPort mainPort) async {
.map((mr) => [mr.left, mr.top, mr.right, mr.bottom])
.toList(),
sw.elapsedMilliseconds,
'planes=${planes.length} yLen=${yPlane.length} stride=${strides[0]} '
'y:min=$yMin max=$yMax mean=${yMean.toStringAsFixed(1)} '
'diff=${yDiff < 0 ? '-' : yDiff.toStringAsFixed(3)} '
'uv1:[$uv1] | ${detectors.first.yuvDiag}$dualDiag',
]);
break;
case 'reset':
motion?.reset();
background?.reset();
for (final d in detectors) {
d.resetYuvMode();
}
break;
case 'set-min-score':
for (final d in detectors) {
d.minScore = (list[1] as num).toDouble();
}
mainPort.send(['log', 'min-score=${detectors.isEmpty ? '-' : detectors.first.minScore}']);
}
} catch (e) {
mainPort.send(['error', '$e']);
} catch (e, st) {
mainPort.send([
'error',
'$e\n${st.toString().split('\n').take(3).join('\n')}'
]);
}
}
}
/// 多模型结果合并:按类别分组,组内 NMS(不同模型检出同一目标时取高分)。
/// 各模型类别体系独立(如野鸡/疑似 vs 野兔/疑似),不同类别互不压制。
List<DetectionResult> mergeAcrossModels(
List<DetectionResult> all, double iouThreshold) {
if (all.length <= 1) return all;
final byLabel = <String, List<DetectionResult>>{};
for (final r in all) {
byLabel.putIfAbsent(r.label, () => []).add(r);
}
final merged = <DetectionResult>[];
for (final group in byLabel.values) {
merged.addAll(nms(group, iouThreshold));
}
merged.sort((a, b) => b.score.compareTo(a.score));
return merged;
}
+246 -39
View File
@@ -1,5 +1,6 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:tflite_flutter/tflite_flutter.dart';
import 'detection_result.dart';
@@ -10,9 +11,12 @@ import 'nms.dart';
/// cx/cy/w/h 已归一化,类别得分已过 sigmoid;按 out[dim][anchor] 索引。
/// 输入为 NCHW [1, 3, 704, 704]litert 导出保留 torch 布局)。
class TfliteDetector {
static const int inputSize = 704;
// 野鸡数据置信度普遍偏低(0.1~0.2 量级),保留低分池供运动检测提升
static const double minScore = 0.10;
// 输入尺寸取自模型本身(ultralytics litert 导出 NCHW [1,3,H,W],各数据集
// 训练 imgsz 可不同),默认 704 兜底
static const int defaultInputSize = 704;
// 野鸡数据置信度普遍偏低(0.1~0.2 量级),保留低分池供运动检测提升;
// 可运行时调整(设置页滑块),默认 0.10
double minScore = 0.10;
static const double iouThreshold = 0.45;
static const int maxDetections = 20;
static const String modelAsset = 'assets/model.tflite';
@@ -22,39 +26,55 @@ class TfliteDetector {
final List<String> _labels;
final int _numClasses;
final int _numAnchors;
final int inputSize;
final Float32List _input =
Float32List(1 * inputSize * inputSize * 3);
/// 模型身份(多模型并行推理区分来源;内置资产模型为 -1/空)
final int modelId;
final String modelName;
late final Float32List _input;
/// 输出按模型形状 [1, 4+nc, anchors] 的嵌套 List 组织,
/// run() 要求输出对象形状与模型完全一致(扁平 List 会被拒)。
final List<List<List<double>>> _output;
TfliteDetector._(this._interpreter, this._labels, this._numClasses,
this._numAnchors, this._output);
this._numAnchors, this._output, this.inputSize, this.modelId,
this.modelName) {
_input = Float32List(1 * inputSize * inputSize * 3);
}
/// 模型缺失或加载失败返回 null(App 降级为仅预览)。
/// 在后台 isolate 内调用(模型字节由主 isolate 读取后传入)。
static Future<TfliteDetector?> fromBuffer(
Uint8List bytes, List<String> labels) async {
Uint8List bytes,
List<String> labels, {
int modelId = -1,
String modelName = '',
}) async {
try {
final interpreter = Interpreter.fromBuffer(
bytes,
options: InterpreterOptions()..threads = 4,
);
return TfliteDetector._fromModel(interpreter, labels);
return TfliteDetector._fromModel(
interpreter, labels, modelId, modelName);
} catch (_) {
return null;
}
}
/// 输出布局 [1, 4+nc, anchors] 取自模型本身,类别数不与 labels 文件长度耦合。
factory TfliteDetector._fromModel(
Interpreter interpreter, List<String> labels) {
factory TfliteDetector._fromModel(Interpreter interpreter,
List<String> labels, int modelId, String modelName) {
final shape = interpreter.getOutputTensor(0).shape;
final numClasses =
shape.length >= 3 && shape[1] > 4 ? shape[1] - 4 : labels.length;
final numAnchors = shape.length >= 3 && shape[2] > 0 ? shape[2] : 2100;
final inputShape = interpreter.getInputTensor(0).shape;
final inputSize = inputShape.length >= 4
? inputShape[3]
: defaultInputSize;
final output = List.generate(
1,
(_) => List.generate(
@@ -62,8 +82,8 @@ class TfliteDetector {
(_) => List<double>.filled(numAnchors, 0),
),
);
return TfliteDetector._(
interpreter, labels, numClasses, numAnchors, output);
return TfliteDetector._(interpreter, labels, numClasses, numAnchors,
output, inputSize, modelId, modelName);
}
/// 原始数据接口(后台 isolate 用,不依赖 CameraImage)。
@@ -75,13 +95,15 @@ class TfliteDetector {
required int width,
required int height,
required bool isBgra,
bool rgbaOrder = false,
}) {
preprocess(
planes: planes,
strides: strides,
width: width,
height: height,
isBgra: isBgra);
isBgra: isBgra,
rgbaOrder: rgbaOrder);
// 传原始字节视图而非 Float32Listtflite_flutter 会对非 ByteBuffer/Uint8List
// 输入调用 resizeInputTensor1 维 [1486848]),使 node 0 TRANSPOSE prepare 失败
_interpreter.run(_input.buffer.asUint8List(), _output);
@@ -103,30 +125,36 @@ class TfliteDetector {
.toList();
}
/// 按像素格式分派:iOS bgra8888 单平面 / Android yuv420 多平面。
/// 按像素格式分派:单平面 RGBA/BGRAiOS bgra8888 / Android 实验) / yuv420 多平面。
void preprocess({
required List<Uint8List> planes,
required List<int> strides,
required int width,
required int height,
required bool isBgra,
bool rgbaOrder = false,
}) {
if (isBgra) {
_preprocessBgra(planes[0], strides[0], width, height);
_preprocessBgra(planes[0], strides[0], width, height, rgbaOrder);
} else {
_preprocessYuv(planes, strides, width, height);
}
}
/// BGRA8888 单平面(iOS):每像素 4 字节 [b,g,r,a],双线性采样,
/// letterbox(等比缩到长边 704,短边黑边补 0,与 YOLO 训练一致)。
void _preprocessBgra(Uint8List src, int stride, int srcW, int srcH) {
/// 单平面 8888iOS bgra8888 = [b,g,r,a]Android 实验 RGBA_8888 = [r,g,b,a]):
/// 每像素 4 字节,双线性采样,letterbox(等比缩到长边 704,短边黑边补 0)。
void _preprocessBgra(
Uint8List src, int stride, int srcW, int srcH, bool rgbaOrder) {
final plane = inputSize * inputSize;
final scale = inputSize / srcW < inputSize / srcH
? inputSize / srcW
: inputSize / srcH;
final dx = (inputSize - srcW * scale) / 2;
final dy = (inputSize - srcH * scale) / 2;
// rgbaOrder=falseiOS BGRA: +0 B、+1 G、+2 R、+3 A
// rgbaOrder=trueAndroid RGBA: +0 R、+1 G、+2 B、+3 A
final rOff = rgbaOrder ? 0 : 2;
final bOff = rgbaOrder ? 2 : 0;
for (var oy = 0; oy < inputSize; oy++) {
final syf = (oy - dy) / scale;
@@ -153,23 +181,22 @@ class TfliteDetector {
final y1 = y0 < srcH - 1 ? y0 + 1 : y0;
final fx = sxf - x0, fy = syf - y0;
// BGRA 字节序:+0 B、+1 G、+2 R、+3 A
final i00 = y0 * stride + x0 * 4;
final i10 = y0 * stride + x1 * 4;
final i01 = y1 * stride + x0 * 4;
final i11 = y1 * stride + x1 * 4;
final r00 = src[i00 + 2].toDouble();
final r00 = src[i00 + rOff].toDouble();
final g00 = src[i00 + 1].toDouble();
final b00 = src[i00].toDouble();
final r10 = src[i10 + 2].toDouble();
final b00 = src[i00 + bOff].toDouble();
final r10 = src[i10 + rOff].toDouble();
final g10 = src[i10 + 1].toDouble();
final b10 = src[i10].toDouble();
final r01 = src[i01 + 2].toDouble();
final b10 = src[i10 + bOff].toDouble();
final r01 = src[i01 + rOff].toDouble();
final g01 = src[i01 + 1].toDouble();
final b01 = src[i01].toDouble();
final r11 = src[i11 + 2].toDouble();
final b01 = src[i01 + bOff].toDouble();
final r11 = src[i11 + rOff].toDouble();
final g11 = src[i11 + 1].toDouble();
final b11 = src[i11].toDouble();
final b11 = src[i11 + bOff].toDouble();
_input[p] = _bl(r00, r10, r01, r11, fx, fy) / 255.0;
_input[p + plane] = _bl(g00, g10, g01, g11, fx, fy) / 255.0;
@@ -179,25 +206,27 @@ class TfliteDetector {
}
/// letterbox 缩放 + YUV → RGB 归一化 0~1NCHW),双线性采样。
/// 兼容 NV12iOS 双平面,UV 交错)与 I420Android 三平面)。
/// 兼容 NV12(双平面,UV 交错)与 I420(三平面)。
/// 首帧自适应:Y 值域(full/limited)与色序(U 先/V 先)因设备而异,
/// 静态假设会在部分机型上产生偏色 → 检测退化。
void _preprocessYuv(
List<Uint8List> planes, List<int> strides, int srcW, int srcH) {
final plane = inputSize * inputSize;
final y = planes[0];
final nv12 = planes.length == 2;
final uv = nv12 ? planes[1] : null;
final u = nv12 ? null : planes[1];
final v = nv12 ? null : planes[2];
final yStride = strides[0];
final uvStride = strides[1];
final vStride =
nv12 ? uvStride : (strides.length > 2 ? strides[2] : strides[1]);
// U/V 平面采样(nv12:偶位 U 奇位 V;i420平面分离
// 色序修正后的 U/V 采样(nv12:偶位 U 奇位 VNV21 相反i420:平面 1/2 对调
double uAt(int x, int y) => nv12
? uv![y * uvStride + x * 2] - 128.0
: u![y * uvStride + x] - 128.0;
? uv![y * uvStride + (_yuvSwapChroma ? x * 2 + 1 : x * 2)] - 128.0
: planes[_yuvSwapChroma ? 2 : 1][y * uvStride + x] - 128.0;
double vAt(int x, int y) => nv12
? uv![y * uvStride + x * 2 + 1] - 128.0
: v![y * uvStride + x] - 128.0;
? uv![y * uvStride + (_yuvSwapChroma ? x * 2 : x * 2 + 1)] - 128.0
: planes[_yuvSwapChroma ? 1 : 2][y * vStride + x] - 128.0;
final scale = inputSize / srcW < inputSize / srcH
? inputSize / srcW
@@ -256,10 +285,11 @@ class TfliteDetector {
final v11 = vAt(ux1, uy1);
final vv = _bl(v00, v10, v01, v11, fx, fy);
// 有限范围展开(VideoRange Y 16~235Cb/Cr 16~240
final yr = (yy - 16.0) * (255.0 / 219.0);
final un = uu * (255.0 / 224.0);
final vn = vv * (255.0 / 224.0);
// 值域展开:有限范围 VideoRangeY 16~235Cb/Cr 16~240需线性拉伸;
// 全值域相机直接使用原始值(与 iOS bgra 一致)
final yr = _yuvFullRange ? yy : (yy - 16.0) * (255.0 / 219.0);
final un = _yuvFullRange ? uu : uu * (255.0 / 224.0);
final vn = _yuvFullRange ? vv : vv * (255.0 / 224.0);
// NCHWr/g/b 分平面存储
_input[p] = (yr + 1.402 * vn) / 255.0;
@@ -269,6 +299,155 @@ class TfliteDetector {
}
}
/// 首帧自适应判定 YUV 模式,后续帧复用(相机重启后由 worker 复位重判)。
/// - 值域:有限范围黑电平恒为 16,低于 12 只可能是全值域。
/// - 色序:以模型本身为 oracle——同一帧按两种色序各推理一次,
/// 检测数/最高分/总分更高者为真;两序均无检测时退回亮区色相计数启发
/// (户外最亮区域为天空应偏蓝,若按默认 U 先序解出偏红则为 V 先序)。
/// - 首帧可能曝光未收敛(过暗/全黑),此时 oracle 与启发式都不可信,
/// 保持未判定状态等下一帧,避免在垃圾帧上锁死错误色序(真机零检测根因)。
bool _yuvFullRange = false;
bool _yuvSwapChroma = false;
bool _yuvModeKnown = false;
bool _yuvRetried = false;
int _yuvDecisionMs = 0;
String _yuvDiag = '';
bool get yuvModeKnown => _yuvModeKnown;
bool get yuvRetried => _yuvRetried;
int get yuvDecisionMs => _yuvDecisionMs;
String get yuvDiag => _yuvDiag;
void decideYuvChroma({
required List<Uint8List> planes,
required List<int> strides,
required int width,
required int height,
bool force = false,
}) {
if (_yuvModeKnown && !force) return;
final (yMin, yMax, yMean) = _yStats(planes[0], strides[0], width, height);
_yuvFullRange = yMin < 12;
if (yMean < 30 || yMax < 170) {
// 曝光未稳定:保持未判定,下一帧重试;始终昏暗则维持默认(同旧版)
if (!_yuvModeKnown) {
_yuvDiag = '等稳定帧 mean=${yMean.toStringAsFixed(0)} max=$yMax';
}
return;
}
_yuvModeKnown = true;
_yuvDecisionMs = DateTime.now().millisecondsSinceEpoch;
final a = _runWithSwap(planes, strides, width, height, false);
final b = _runWithSwap(planes, strides, width, height, true);
var swap = false;
if (a.$1 != b.$1) {
swap = b.$1 > a.$1;
} else if (a.$2 != b.$2) {
swap = b.$2 > a.$2;
} else if (a.$3 != b.$3) {
swap = b.$3 > a.$3;
} else {
swap = _brightRegionLeansRed(planes, strides, width, height, yMax);
}
_yuvSwapChroma = swap;
_yuvDiag = 'full=$_yuvFullRange swap=$_yuvSwapChroma'
' cA=${a.$1} sA=${a.$2.toStringAsFixed(3)}'
' cB=${b.$1} sB=${b.$2.toStringAsFixed(3)}';
debugPrint('[yuv] $_yuvDiag mean=${yMean.toStringAsFixed(0)} max=$yMax');
}
/// 判定后持续无检测的自愈:用实时帧重跑完整判定(仅一次)。
/// 首帧模糊/暗帧导致启发式猜错时,等画面稳定后 oracle 即可分胜负。
/// 返回是否执行了重判(随后应重跑 detectRaw 取新结果)。
bool retryDecision({
required List<Uint8List> planes,
required List<int> strides,
required int width,
required int height,
}) {
if (_yuvRetried || !_yuvModeKnown) return false;
final (_, yMax, yMean) = _yStats(planes[0], strides[0], width, height);
if (yMean < 30 || yMax < 170) return false; // 帧仍不可用
_yuvRetried = true;
decideYuvChroma(
planes: planes, strides: strides, width: width, height: height,
force: true);
return true;
}
/// 相机(重新)启动后复位,首帧重新判定
void resetYuvMode() {
_yuvModeKnown = false;
_yuvRetried = false;
_yuvDiag = '';
}
/// 采样统计 Y 值域:(min, max, mean),步长 16px 约 3600 样本
(int, int, double) _yStats(Uint8List y, int yStride, int w, int h) {
var yMin = 255, yMax = 0;
var sum = 0, n = 0;
for (var j = 0; j < h; j += 16) {
final row = j * yStride;
for (var i = 0; i < w; i += 16) {
final v = y[row + i];
if (v < yMin) yMin = v;
if (v > yMax) yMax = v;
sum += v;
n++;
}
}
return (yMin, yMax, sum / n);
}
/// 按指定色序推理一次,返回 (检测数, 最高分, 总分)
(int, double, double) _runWithSwap(
List<Uint8List> planes, List<int> strides, int w, int h, bool swap) {
_yuvSwapChroma = swap;
preprocess(planes: planes, strides: strides, width: w, height: h,
isBgra: false);
_interpreter.run(_input.buffer.asUint8List(), _output);
final dets = postprocess();
var maxScore = 0.0, sumScore = 0.0;
for (final d in dets) {
sumScore += d.score;
if (d.score > maxScore) maxScore = d.score;
}
return (dets.length, maxScore, sumScore);
}
bool _brightRegionLeansRed(
List<Uint8List> planes, List<int> strides, int w, int h, int yMax) {
final y = planes[0];
final yStride = strides[0];
final uvStride = strides[1];
final nv12 = planes.length == 2;
final uv = nv12 ? planes[1] : null;
// 最亮带(maxY-40 以上),整体偏暗的场景也能拿到足量样本
final brightMin = yMax - 40;
var blue = 0, red = 0;
for (var j = 0; j < h; j += 8) {
final yrow = j * yStride;
for (var i = 0; i < w; i += 8) {
if (y[yrow + i] < brightMin) continue;
final cj = j ~/ 2, ci = i ~/ 2;
if (nv12) {
final c = cj * uvStride + ci * 2;
if (c + 1 >= uv!.length) continue;
if (uv[c] > 150) blue++;
if (uv[c + 1] > 150) red++;
} else {
final c = cj * uvStride + ci;
if (c >= planes[1].length || c >= planes[2].length) continue;
if (planes[1][c] > 150) blue++;
if (planes[2][c] > 150) red++;
}
}
}
// 亮区偏红多于偏蓝 → 当前 U/V 假设反了
return red > blue;
}
static double _bl(double a, double b, double c, double d, double fx,
double fy) =>
(1 - fx) * (1 - fy) * a + fx * (1 - fy) * b +
@@ -302,11 +481,39 @@ class TfliteDetector {
top: (cy - h / 2).clamp(0.0, 1.0),
right: (cx + w / 2).clamp(0.0, 1.0),
bottom: (cy + h / 2).clamp(0.0, 1.0),
modelId: modelId,
modelName: modelName,
));
}
final kept = nms(boxes, iouThreshold);
return kept.take(maxDetections).toList();
}
/// 诊断:按指定字节序推理一次,返回 (检测数, 最高分)。
/// 用于对比 BGRA/RGBA 两种顺序在同一帧上的检测差异(验证字节序与场景可达性)。
(int, double) diagnoseOrder({
required List<Uint8List> planes,
required List<int> strides,
required int width,
required int height,
required bool isBgra,
required bool rgbaOrder,
}) {
preprocess(
planes: planes,
strides: strides,
width: width,
height: height,
isBgra: isBgra,
rgbaOrder: rgbaOrder);
_interpreter.run(_input.buffer.asUint8List(), _output);
final dets = postprocess();
var maxScore = 0.0;
for (final d in dets) {
if (d.score > maxScore) maxScore = d.score;
}
return (dets.length, maxScore);
}
void dispose() => _interpreter.close();
}
+15 -6
View File
@@ -34,12 +34,14 @@ class VisualPrior {
required int width,
required int height,
required bool isBgra,
required bool rgbaOrder,
}) {
if (results.isEmpty || width <= 0 || height <= 0) return results;
final kept = <DetectionResult>[];
for (final r in results) {
final lowConfPheasant = r.label == 'pheasant' && r.score < maxScore;
if (lowConfPheasant && _reject(r, planes, strides, width, height, isBgra)) {
if (lowConfPheasant &&
_reject(r, planes, strides, width, height, isBgra, rgbaOrder)) {
continue;
}
kept.add(r);
@@ -48,7 +50,7 @@ class VisualPrior {
}
static bool _reject(DetectionResult r, List<Uint8List> planes,
List<int> strides, int width, int height, bool isBgra) {
List<int> strides, int width, int height, bool isBgra, bool rgbaOrder) {
// 位置线索:detectRaw 输出为图像坐标系,centerY 直接可判天空区
if (r.centerY < skyTopRatio) return true;
@@ -63,7 +65,8 @@ class VisualPrior {
for (var gx = -2; gx <= 2; gx++) {
final px = (cx + gx * halfW / 2).round().clamp(0, width - 1).toInt();
final py = (cy + gy * halfH / 2).round().clamp(0, height - 1).toInt();
final (r_, g_, b_) = _pixel(planes, strides, px, py, width, height, isBgra);
final (r_, g_, b_) =
_pixel(planes, strides, px, py, width, height, isBgra, rgbaOrder);
total++;
final mn = math.min(r_, math.min(g_, b_));
final mx = math.max(r_, math.max(g_, b_));
@@ -80,14 +83,20 @@ class VisualPrior {
}
/// 读取单像素 RGB0~255)。
/// BGRA 单平面:每像素 4 字节 [b,g,r,a]
/// 8888 单平面按实际字节序取通道:BGRA=[b,g,r,a]iOS 插件)、
/// RGBA=[r,g,b,a]Android 自写原生通道)——字节序写死会让 Android
/// 低分框采样到 R/B 互换的颜色(橙色野鸡身被误判成"蓝色")整批误杀;
/// YUVy 平面 + 4:2:0 半分辨率 U/VNV12 交错或 I420 分离)。
static (double, double, double) _pixel(List<Uint8List> planes,
List<int> strides, int x, int y, int width, int height, bool isBgra) {
List<int> strides, int x, int y, int width, int height, bool isBgra,
bool rgbaOrder) {
if (isBgra) {
final src = planes[0];
final i = y * strides[0] + x * 4;
return (src[i + 2].toDouble(), src[i + 1].toDouble(), src[i].toDouble());
final rOff = rgbaOrder ? 0 : 2;
final bOff = rgbaOrder ? 2 : 0;
return (src[i + rOff].toDouble(), src[i + 1].toDouble(),
src[i + bOff].toDouble());
}
final yy =
(planes[0][y * strides[0] + x] - 16.0) * (255.0 / 219.0);
+5 -5
View File
@@ -6,7 +6,7 @@ import 'package:provider/provider.dart';
import '../auth/session_store.dart';
/// 使用协议页:首次启动强制展示,同意后记录(secure storage),拒绝则退出应用。
/// 用途声明:仅限野生动物观察,禁止近距离打扰与非法使用。
/// 用途声明:仅限动物观察,禁止近距离打扰与非法使用。
class TermsScreen extends StatefulWidget {
const TermsScreen({super.key});
@@ -28,12 +28,12 @@ class _TermsScreenState extends State<TermsScreen> {
感谢您使用「视野」动物观察应用。使用本应用前,请仔细阅读并同意以下条款:
一、用途限制
本应用仅用于野生动物观察、记录与科普学习,严禁用于任何非法用途。
本应用仅用于动物观察、记录与科普学习,严禁用于任何非法用途。
二、禁止行为
1. 禁止近距离打扰、追逐、驱赶、恐吓野生动物;
2. 禁止投喂、诱捕、猎捕、伤害野生动物;
3. 禁止利用本应用从事盗猎、野生动物交易或其他违法犯罪活动;
1. 禁止近距离打扰、追逐、驱赶、恐吓动物;
2. 禁止投喂、诱捕、猎捕、伤害动物;
3. 禁止利用本应用从事盗猎、动物交易或其他违法犯罪活动;
4. 禁止进入自然保护区禁区、私人领地等未经许可的区域进行观察。
三、法律合规
+285
View File
@@ -0,0 +1,285 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart' show sha256;
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import '../config/app_config.dart';
/// 模型目录条目(GET /api/v1/app/update 响应 data.models[])。
/// 服务器发布模型后随版本检查一同下发,App 按目录逐数据集下载/更新。
class ModelCatalogItem {
final int datasetId;
final String datasetName;
final String version;
final List<String> labels;
final int sizeBytes;
final String sha256;
final String downloadUrl;
const ModelCatalogItem({
required this.datasetId,
required this.datasetName,
required this.version,
required this.labels,
required this.sizeBytes,
required this.sha256,
required this.downloadUrl,
});
factory ModelCatalogItem.fromJson(Map<String, dynamic> j) =>
ModelCatalogItem(
datasetId: (j['datasetId'] as num?)?.toInt() ?? 0,
datasetName: j['datasetName'] as String? ?? '',
version: j['version'] as String? ?? '',
labels: (j['labels'] as List? ?? const [])
.map((e) => e.toString())
.toList(),
sizeBytes: (j['sizeBytes'] as num?)?.toInt() ?? 0,
sha256: j['sha256'] as String? ?? '',
downloadUrl: j['downloadUrl'] as String? ?? '',
);
}
/// 已就绪模型(字节 + 标签,供推理 worker 加载;含内置资产回退模型)
class ModelBundle {
final int datasetId;
final String datasetName;
final String version;
final List<String> labels;
final Uint8List bytes;
const ModelBundle({
required this.datasetId,
required this.datasetName,
required this.version,
required this.labels,
required this.bytes,
});
}
/// 模型热更新管理:启动时拉取模型目录(随 /app/update 公开接口下发,无需登录态),
/// 按需下载/校验/持久化各数据集模型,供相机页多模型并行推理。
///
/// 存储:应用私有目录 `models/<datasetId>/`model.tflite + labels.json + meta.json),
/// meta 记录 {version, sha256},服务器发布新版本时按版本+摘要重下,不重复下载旧模型。
class ModelManager extends ChangeNotifier {
static final ModelManager instance = ModelManager._();
final String baseUrl;
final http.Client _client;
final Future<Directory> Function()? _rootDirOverride;
List<ModelBundle> _models = const [];
bool _ready = false;
bool _refreshing = false;
String? _error;
Future<void>? _inFlight;
ModelManager._({String? baseUrl, http.Client? client})
: this(baseUrl: baseUrl, client: client);
/// 可注入 baseUrl / client / 存储根目录(单测用)
@visibleForTesting
ModelManager({
String? baseUrl,
http.Client? client,
Future<Directory> Function()? rootDir,
}) : baseUrl = baseUrl ?? AppConfig.apiBaseUrl,
_client = client ?? http.Client(),
_rootDirOverride = rootDir;
/// 已就绪模型列表(空 = 无服务器模型,回退内置资产)
List<ModelBundle> get models => _models;
/// 是否成功拉取过目录(即使下载失败也为 true,用于区分"从未联网"与"目录为空"
bool get ready => _ready;
/// 最近一次同步的错误信息(下载失败/校验失败等;目录为空不算错误)
String? get error => _error;
bool get refreshing => _refreshing;
/// 模型名摘要(诊断行展示):内置 / 数据集名×n
String get modelsLabel {
if (_models.isEmpty) return '内置';
return _models.map((m) => m.datasetName).join(',');
}
/// 拉取目录并同步本地模型;并发调用共享同一进行中的刷新。
Future<void> refresh() {
if (_refreshing) return _inFlight ?? Future.value();
_refreshing = true;
_inFlight = _doRefresh().whenComplete(() {
_refreshing = false;
_inFlight = null;
notifyListeners();
});
return _inFlight!;
}
Future<void> _doRefresh() async {
try {
final res = await _client
.get(Uri.parse('$baseUrl/api/v1/app/update'))
.timeout(const Duration(seconds: 8));
// 服务器 Content-Type 无 charsethttp 包默认按 latin1 解码会乱码 → 显式 utf8
final body =
jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
final data = body['data'] as Map<String, dynamic>? ?? const {};
final list = data['models'] as List? ?? const [];
final catalog = list
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
.toList();
final failed = <String>[];
for (final item in catalog) {
if (!await _ensureLocal(item)) failed.add(item.datasetName);
}
await _prune(catalog);
_models = await _loadBundles(catalog);
_ready = true;
_error = failed.isEmpty
? null
: '模型下载失败:${failed.join(',')}(重试旧模型或稍后再试)';
} catch (e) {
if (!_ready) _error = '模型目录拉取失败:$e';
// 已就绪过则保留旧模型,不覆盖 error(下载级错误优先展示)
}
}
/// 保证目录条目在本地可用:meta 匹配且文件在 → 跳过;否则下载并校验 sha256。
Future<bool> _ensureLocal(ModelCatalogItem item) async {
final dir = await _modelDir(item.datasetId);
try {
final meta = await _readMeta(dir);
final file = File('${dir.path}/model.tflite');
if (meta != null &&
meta['version'] == item.version &&
meta['sha256'] == item.sha256 &&
await file.exists()) {
return true;
}
// 版本更新或文件缺失:下载校验(失败重试一次)
for (var attempt = 0; attempt < 2; attempt++) {
final ok = await _downloadAndVerify(item, dir, file);
if (ok) return true;
await file.delete().catchError((_) => file);
await File('${dir.path}/model.tflite.part')
.delete()
.catchError((_) => file);
}
debugPrint('[ModelManager] 下载失败: ${item.datasetName} ${item.version}');
return false;
} catch (e) {
debugPrint('[ModelManager] _ensureLocal ${item.datasetName}: $e');
return false;
}
}
Future<bool> _downloadAndVerify(
ModelCatalogItem item, Directory dir, File file) async {
final part = File('${file.path}.part');
final sink = part.openWrite();
try {
final res = await _client
.send(http.Request('GET', Uri.parse('$baseUrl${item.downloadUrl}')))
.timeout(const Duration(minutes: 3));
if (res.statusCode != 200) return false;
await res.stream.pipe(sink);
await sink.close();
final bytes = await part.readAsBytes();
final hex = sha256.convert(bytes).toString();
if (item.sha256.isNotEmpty && hex != item.sha256) {
debugPrint('[ModelManager] sha256 不匹配: ${item.datasetName} '
'want=${item.sha256} got=$hex');
return false;
}
await part.rename(file.path);
await dir.create(recursive: true);
await File('${dir.path}/labels.json')
.writeAsString(jsonEncode(item.labels));
await File('${dir.path}/meta.json').writeAsString(jsonEncode({
'version': item.version,
'sha256': item.sha256,
}));
debugPrint('[ModelManager] 已下载 ${item.datasetName} '
'${bytes.length}B -> ${file.path}');
return true;
} catch (e) {
await sink.close().catchError((_) {});
debugPrint('[ModelManager] 下载异常 ${item.datasetName}: $e');
return false;
}
}
/// 清理服务器目录中已下线的数据集模型(不再发布则删本地)
Future<void> _prune(List<ModelCatalogItem> catalog) async {
final root = await _rootDir();
if (!await root.exists()) return;
final keep = catalog.map((c) => '${c.datasetId}').toSet();
await for (final e in root.list()) {
if (e is Directory) {
// 目录 URI 末尾带 '/'pathSegments 末位为空串 → 过滤后取目录名
final name = e.uri.pathSegments.where((s) => s.isNotEmpty).last;
if (!keep.contains(name)) {
await e.delete(recursive: true).catchError((_) => e);
}
}
}
}
Future<List<ModelBundle>> _loadBundles(
List<ModelCatalogItem> catalog) async {
final bundles = <ModelBundle>[];
for (final item in catalog) {
try {
final dir = await _modelDir(item.datasetId);
final file = File('${dir.path}/model.tflite');
if (!await file.exists()) continue;
final labels = await File('${dir.path}/labels.json').exists()
? (jsonDecode(
await File('${dir.path}/labels.json').readAsString())
as List)
.map((e) => e.toString())
.toList()
: item.labels;
bundles.add(ModelBundle(
datasetId: item.datasetId,
datasetName: item.datasetName,
version: item.version,
labels: labels,
bytes: await file.readAsBytes(),
));
} catch (e) {
debugPrint('[ModelManager] 读取 ${item.datasetName} 失败: $e');
}
}
return bundles;
}
Future<Map<String, dynamic>?> _readMeta(Directory dir) async {
final f = File('${dir.path}/meta.json');
if (!await f.exists()) return null;
try {
return jsonDecode(await f.readAsString()) as Map<String, dynamic>;
} catch (_) {
return null;
}
}
Future<Directory> _rootDir() async {
if (_rootDirOverride != null) return _rootDirOverride();
final support = await getApplicationSupportDirectory();
return Directory('${support.path}/models');
}
Future<Directory> _modelDir(int datasetId) async {
final root = await _rootDir();
final dir = Directory('${root.path}/$datasetId');
await dir.create(recursive: true);
return dir;
}
}
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'models.dart';
@@ -73,6 +74,24 @@ class _PaywallScreenState extends State<PaywallScreen> {
}
},
),
const SizedBox(height: 12),
_PayButton(
label: '联系管理员',
icon: Icons.headset_mic,
color: Colors.grey.shade600,
enabled: true,
onTap: () async {
await Clipboard.setData(
const ClipboardData(text: 'wenwu901'),
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('已复制管理员微信号,请使用微信添加联系人'),
),
);
},
),
if (vm.state.state == PayState.failed)
Padding(
padding: const EdgeInsets.only(top: 16),
+49
View File
@@ -0,0 +1,49 @@
import 'package:flutter/services.dart';
/// 安装进度事件(原生 PackageInstaller 会话回调经 EventChannel 回传)
class InstallEvent {
/// progress / finished / failed
final String event;
/// event=progress 时的安装进度 0-100
final int progress;
/// event=finished 时是否安装成功
final bool? success;
/// event=failed 时的错误描述
final String? error;
const InstallEvent({
required this.event,
this.progress = 0,
this.success,
this.error,
});
}
/// App 内安装 APK:原生侧 PackageInstaller 会话安装(InstallerChannel.kt
class ApkInstaller {
static const _method = MethodChannel('observer/installer');
static const _progress = EventChannel('observer/installer/progress');
/// 提交安装。返回 installing(已进入安装流程)/ permission_required
/// (未允许「安装未知应用」,原生侧已拉起系统设置页)。
static Future<String> install(String path) =>
_method.invokeMethod<String>('install', {'path': path}).then(
(v) => v ?? 'installing',
);
/// 安装进度流:progress(0-100) → finished(success) / failed(error)
static Stream<InstallEvent> progress() {
return _progress.receiveBroadcastStream().map((e) {
final m = e as Map;
return InstallEvent(
event: m['event'] as String? ?? '',
progress: (m['progress'] as num?)?.toInt() ?? 0,
success: m['success'] as bool?,
error: m['error'] as String?,
);
});
}
}
@@ -0,0 +1,86 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../config/app_config.dart';
/// App 版本更新信息(GET /api/v1/app/update 响应 data;无记录时为空信息)
class AppUpdateInfo {
final String version;
final String notes;
const AppUpdateInfo({required this.version, required this.notes});
/// 服务器无版本记录时返回空信息,调用方视为无需更新
bool get isEmpty => version.isEmpty;
}
/// 启动时版本更新检查:仅 Android 检查;服务器版本高于本地版本即强制更新
/// (无普通/强制之分)。
class UpdateChecker {
final String baseUrl;
final http.Client _client;
UpdateChecker({String? baseUrl, http.Client? client})
: baseUrl = baseUrl ?? AppConfig.apiBaseUrl,
_client = client ?? http.Client();
/// 下载地址:固定静态路径(管理端上传的 APK 覆盖保存为固定文件)
static String downloadUrl({String? baseUrl}) =>
'${baseUrl ?? AppConfig.apiBaseUrl}/download/observer-latest.apk';
/// 拉取服务器最新版本;非 Android 或网络异常时返回空信息,不阻塞启动
Future<AppUpdateInfo> fetch() async {
if (!Platform.isAndroid) {
return const AppUpdateInfo(version: '', notes: '');
}
try {
final res = await _client
.get(Uri.parse('$baseUrl/api/v1/app/update'))
.timeout(const Duration(seconds: 8));
// 服务器 Content-Type 无 charsethttp 包默认按 latin1 解码会乱码 → 显式 utf8
final body =
jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
final data = body['data'] as Map<String, dynamic>? ?? const {};
return AppUpdateInfo(
version: data['version'] as String? ?? '',
notes: data['notes'] as String? ?? '',
);
} catch (_) {
return const AppUpdateInfo(version: '', notes: '');
}
}
/// 是否需要更新:服务器版本高于「已装版本与已确认接受版本」中的较大者。
/// APK 版本号不递增时,用户点过「立即更新」后 accepted 追上服务器版本,
/// 已更新完成再次启动也不会反复提示。
static bool needsUpdate(String server, String installed, String accepted) {
if (server.isEmpty || installed.isEmpty) return false;
return isNewer(server, installed) || isNewer(server, accepted);
}
/// 语义化版本号比较:a > b 返回 true。按数字段比较(1.10.0 > 1.9.9),
/// 任一版本号解析失败时视为相等(不触发更新)。
static bool isNewer(String a, String b) {
final pa = _parse(a);
final pb = _parse(b);
if (pa == null || pb == null) return false;
for (var i = 0; i < 3; i++) {
if (pa[i] != pb[i]) return pa[i] > pb[i];
}
return false;
}
static List<int>? _parse(String v) {
final parts = v.split('.');
if (parts.length != 3) return null;
final nums = <int>[];
for (final p in parts) {
final n = int.tryParse(p);
if (n == null) return null;
nums.add(n);
}
return nums;
}
}
+238
View File
@@ -0,0 +1,238 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
import 'installer.dart';
/// 强制更新页:检测到新版本时的全屏阻塞页。
/// PopScope 禁返回(Android 系统返回 / iOS 边缘滑动均不可退出)。
/// 点「立即更新」在 App 内流式下载 APK(显示下载进度)→ PackageInstaller
/// 会话安装(显示安装进度),不再跳浏览器。
/// 点击更新时回调 onUpdateAccepted(调用方持久化服务器版本号,
/// 使 APK 版本号不递增时也不反复提示)。
class UpdateScreen extends StatefulWidget {
final String version;
final String url;
final String notes;
final VoidCallback? onUpdateAccepted;
const UpdateScreen({
super.key,
required this.version,
required this.url,
this.notes = '',
this.onUpdateAccepted,
});
@override
State<UpdateScreen> createState() => _UpdateScreenState();
}
enum _Stage { idle, downloading, installing, finished, failed }
class _UpdateScreenState extends State<UpdateScreen> {
final http.Client _client = http.Client();
final File _apkFile = File('${Directory.systemTemp.path}/observer-latest.apk');
final File _apkPart =
File('${Directory.systemTemp.path}/observer-latest.apk.part');
_Stage _stage = _Stage.idle;
/// 进度百分比 0-100;null = 总量未知(不确定进度条)
double? _progress;
String? _message;
StreamSubscription<InstallEvent>? _installSub;
@override
void dispose() {
_installSub?.cancel();
_client.close();
super.dispose();
}
Future<void> _launch() async {
if (_stage == _Stage.downloading ||
_stage == _Stage.installing ||
_stage == _Stage.finished) {
return;
}
setState(() {
_stage = _Stage.idle;
_message = null;
});
widget.onUpdateAccepted?.call();
if (!Platform.isAndroid) {
// 更新检查本就仅 Android 触发,这里兜底非 Android 走浏览器
final uri = Uri.tryParse(widget.url);
if (uri == null) return;
try {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {}
return;
}
// APK 已下载完成(下载是原子落盘,.part 改名后文件才存在)→ 跳过下载直接安装
if (_apkFile.existsSync()) {
await _install();
return;
}
await _download();
}
Future<void> _download() async {
setState(() {
_stage = _Stage.downloading;
_progress = 0;
});
try {
if (_apkPart.existsSync()) _apkPart.deleteSync();
final res = await _client.send(http.Request('GET', Uri.parse(widget.url)));
if (res.statusCode != 200) {
throw HttpException('HTTP ${res.statusCode}');
}
final total = res.contentLength ?? -1;
final sink = _apkPart.openWrite();
var received = 0;
await for (final chunk in res.stream) {
sink.add(chunk);
received += chunk.length;
if (mounted && total > 0) {
setState(() => _progress = received / total * 100);
}
}
await sink.close();
// 原子落盘:下载完成后才重命名为正式文件,避免残留半包被当成完整 APK
_apkPart.renameSync(_apkFile.path);
if (!mounted) return;
await _install();
} catch (_) {
if (_apkPart.existsSync()) _apkPart.deleteSync();
if (!mounted) return;
setState(() {
_stage = _Stage.failed;
_progress = null;
_message = '下载失败,请检查网络后重试';
});
}
}
Future<void> _install() async {
setState(() {
_stage = _Stage.installing;
_progress = 0;
_message = null;
});
await _installSub?.cancel();
_installSub = ApkInstaller.progress().listen((e) {
if (!mounted) return;
switch (e.event) {
case 'progress':
setState(() => _progress = e.progress.toDouble());
break;
case 'finished':
final ok = e.success == true;
setState(() {
_stage = ok ? _Stage.finished : _Stage.failed;
_message = ok ? '安装完成,请从桌面打开新版应用' : '安装失败,请重试';
});
break;
case 'failed':
setState(() {
_stage = _Stage.failed;
_message = e.error ?? '安装失败,请重试';
});
break;
}
}, onError: (Object _) {
if (!mounted) return;
setState(() {
_stage = _Stage.failed;
_message = '安装失败,请重试';
});
});
final result = await ApkInstaller.install(_apkFile.path);
if (!mounted) return;
if (result == 'permission_required') {
// 原生侧已拉起系统设置页;APK 已缓存,用户开启后返回再点直达安装
setState(() {
_stage = _Stage.failed;
_message = '请在系统设置中允许「安装未知应用」,返回后再次点击「立即更新」(APK 已缓存,无需重新下载)';
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final busy = _stage == _Stage.downloading || _stage == _Stage.installing;
final progressText = _stage == _Stage.downloading
? (_progress == null ? '下载中…' : '下载中 ${_progress!.round()}%')
: (_progress == null ? '安装中…' : '安装中 ${_progress!.round()}%');
return PopScope(
canPop: false,
child: Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.system_update_alt,
size: 64, color: theme.colorScheme.primary),
const SizedBox(height: 16),
Text('发现新版本 ${widget.version}',
style: theme.textTheme.headlineSmall),
const SizedBox(height: 12),
if (widget.notes.isNotEmpty)
Text(widget.notes,
textAlign: TextAlign.center,
style: const TextStyle(height: 1.6)),
const SizedBox(height: 24),
if (busy) ...[
LinearProgressIndicator(
value: _progress == null ? null : _progress! / 100,
minHeight: 6,
),
const SizedBox(height: 12),
Text(progressText),
const SizedBox(height: 24),
],
FilledButton.icon(
onPressed: busy || _stage == _Stage.finished
? null
: _launch,
icon: const Icon(Icons.download),
label: Text(_stage == _Stage.finished ? '已完成' : '立即更新'),
style: FilledButton.styleFrom(
minimumSize: const Size(200, 48),
textStyle: const TextStyle(fontSize: 16),
),
),
if (_message != null) ...[
const SizedBox(height: 12),
Text(
_message!,
textAlign: TextAlign.center,
style: TextStyle(
height: 1.5,
color: _stage == _Stage.failed
? theme.colorScheme.error
: Colors.green,
),
),
],
const SizedBox(height: 12),
Text('不更新将无法继续使用',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.error)),
],
),
),
),
),
);
}
}
+68 -4
View File
@@ -90,7 +90,7 @@ packages:
source: hosted
version: "0.11.4"
camera_android_camerax:
dependency: "direct main"
dependency: transitive
description:
name: camera_android_camerax
sha256: "8516fe308bc341a5067fb1a48edff0ddfa57c0d3cdcc9dbe7ceca3ba119e2577"
@@ -162,7 +162,7 @@ packages:
source: hosted
version: "0.3.5+4"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
@@ -497,7 +497,7 @@ packages:
source: hosted
version: "3.0.0"
package_info_plus:
dependency: transitive
dependency: "direct main"
description:
name: package_info_plus
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
@@ -521,7 +521,7 @@ packages:
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
dependency: "direct main"
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
@@ -765,6 +765,70 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.3.32"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.5"
uuid:
dependency: transitive
description:
+10 -3
View File
@@ -1,8 +1,8 @@
name: observer
description: "视野 - 野生动物实时识别 (野鸡/生境), YOLOv8 + 充值付费"
description: "视野 - 动物实时识别 (野鸡/生境), YOLOv8 + 充值付费"
publish_to: 'none'
version: 1.0.0+1
version: 1.0.4+5
environment:
sdk: ^3.12.2
@@ -11,8 +11,10 @@ dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
# Android 已改用自写原生相机通道(android/ 下 CameraChannel.kt,分析帧
# 原生侧旋转成竖屏后回传),不再依赖 camera_android_camerax 插件;
# camera + camera_avfoundation 仅用于 iOS 路径
camera: ^0.11.0
camera_android_camerax: ^0.6.6
camera_avfoundation: ^0.9.17
tflite_flutter: ^0.11.0
flutter_secure_storage: ^9.2.0
@@ -25,6 +27,11 @@ dependencies:
http: ^1.2.0
cupertino_http: ^3.0.2
wakelock_plus: ^1.2.0
package_info_plus: ^9.0.1
url_launcher: ^6.3.2
# 模型热更新:多模型下载(crypto 校验 sha256path_provider 取应用私有目录持久化)
crypto: ^3.0.0
path_provider: ^2.1.4
# 微信/支付宝原生 SDK 配置(占位值,与 lib/config/app_config.dart 一致;接入真实支付时替换。
# 注意:fluwx 的 universal_link 占位符会被其 pod 脚本注入 Associated Domains
+141
View File
@@ -0,0 +1,141 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart' show sha256;
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:observer/models/model_manager.dart';
const _modelBytes = [1, 2, 3, 4, 5, 6, 7, 8];
String _shaHex(List<int> bytes) => sha256.convert(bytes).toString();
Map<String, dynamic> _catalog(List<Map<String, dynamic>> models) =>
{'code': 0, 'message': 'ok', 'data': {'version': '0.0.2', 'notes': '', 'models': models}};
Map<String, dynamic> _item({String version = 'v1.0.0', String sha = ''}) => {
'datasetId': 7,
'datasetName': '野鸡数据集',
'version': version,
'labels': ['pheasant', 'suspect'],
'sizeBytes': _modelBytes.length,
'sha256': sha.isEmpty ? _shaHex(_modelBytes) : sha,
'downloadUrl': '/download/models/野鸡数据集/latest.tflite',
};
void main() {
late Directory root;
late int downloadHits;
setUp(() async {
root = await Directory.systemTemp.createTemp('model-manager-test');
downloadHits = 0;
});
tearDown(() => root.delete(recursive: true));
ModelManager manager(MockClient client) => ModelManager(
baseUrl: 'http://test.local',
client: client,
rootDir: () async => root,
);
MockClient client(List<Map<String, dynamic>> models) => MockClient((req) async {
if (req.url.path == '/api/v1/app/update') {
// Response(String) 默认 latin1 编码,中文数据集名会抛异常 → 用 bytes
return http.Response.bytes(utf8.encode(jsonEncode(_catalog(models))), 200);
}
if (req.url.path.startsWith('/download/models/')) {
downloadHits++;
return http.Response.bytes(_modelBytes, 200);
}
return http.Response('not found', 404);
});
test('首次拉取:下载模型并落盘(model/labels/meta', () async {
final m = manager(client([_item()]));
await m.refresh();
expect(m.ready, isTrue);
expect(m.error, isNull);
expect(m.models.length, 1);
expect(m.models.first.datasetName, '野鸡数据集');
expect(m.models.first.bytes, _modelBytes);
expect(downloadHits, 1);
final dir = Directory('${root.path}/7');
expect(await File('${dir.path}/model.tflite').exists(), isTrue);
expect(await File('${dir.path}/labels.json').exists(), isTrue);
expect(await File('${dir.path}/meta.json').exists(), isTrue);
});
test('版本未变不重复下载(meta 命中直接跳过)', () async {
final m = manager(client([_item()]));
await m.refresh();
expect(downloadHits, 1);
await m.refresh();
expect(downloadHits, 1, reason: 'meta 匹配应跳过下载');
expect(m.models.length, 1);
});
test('版本更新触发重新下载', () async {
final m = manager(client([_item()]));
await m.refresh();
expect(downloadHits, 1);
await m.refresh();
expect(downloadHits, 1);
// 发布新版本:再次刷新应重下
await m.refresh();
expect(downloadHits, 1);
// 上面三次同一版本,重新构造带新版本的 manager(同一 root
final m2 = manager(client([_item(version: 'v2.0.0')]));
await m2.refresh();
expect(downloadHits, 2);
expect(m2.models.first.version, 'v2.0.0');
});
test('sha256 不匹配:重试后失败,保留旧模型并报错', () async {
// 第一次下载成功(sha 匹配)
final m1 = manager(client([_item()]));
await m1.refresh();
expect(m1.models.length, 1);
// 服务器 sha 与文件不符(被篡改/损坏)→ 下载校验失败
final bad = _item(version: 'v3.0.0');
bad['sha256'] = _shaHex([9, 9, 9]);
final m2 = manager(client([bad]));
await m2.refresh();
expect(m2.models.length, 0, reason: '校验失败的模型不应加载');
expect(m2.error, isNotNull);
expect(m2.error, contains('野鸡数据集'));
});
test('目录下线:清理本地并清空模型', () async {
final m = manager(client([_item()]));
await m.refresh();
expect(m.models.length, 1);
expect(await Directory('${root.path}/7').exists(), isTrue);
final m2 = manager(client([]));
await m2.refresh();
expect(m2.models, isEmpty);
expect(await Directory('${root.path}/7').exists(), isFalse,
reason: '下线的数据集模型目录应被清理');
});
test('目录接口异常:不覆盖已有就绪状态', () async {
final m = manager(client([_item()]));
await m.refresh();
expect(m.ready, isTrue);
final broken = manager(MockClient((_) async => http.Response('boom', 500)));
await broken.refresh();
expect(broken.ready, isFalse);
expect(broken.error, isNotNull);
});
}
+55
View File
@@ -0,0 +1,55 @@
import 'package:observer/detection/detection_result.dart';
import 'package:observer/detection/detector_worker.dart';
import 'package:flutter_test/flutter_test.dart';
DetectionResult box(String label, double score, double x, double y,
{int modelId = -1, String modelName = ''}) =>
DetectionResult(
label: label,
score: score,
left: x,
top: y,
right: x + 0.1,
bottom: y + 0.1,
modelId: modelId,
modelName: modelName,
);
void main() {
test('不同模型同标签重复框:NMS 去重取高分', () {
// 野鸡模型与野兔模型都检出了同一只"野鸡"(不同模型对同一目标的重复框)
final all = [
box('pheasant', 0.18, 0.3, 0.3, modelId: 1, modelName: '野鸡模型'),
box('pheasant', 0.55, 0.31, 0.3, modelId: 2, modelName: '野兔模型'),
];
final merged = mergeAcrossModels(all, 0.45);
expect(merged.length, 1);
expect(merged.first.score, 0.55);
expect(merged.first.modelName, '野兔模型');
});
test('不同类别互不压制', () {
final all = [
box('pheasant', 0.3, 0.5, 0.5, modelId: 1),
box('hare', 0.7, 0.5, 0.5, modelId: 2), // 同位置但不同类别
];
final merged = mergeAcrossModels(all, 0.45);
expect(merged.length, 2);
});
test('同模型内部与跨模型合并一致:远处不重叠保留', () {
final all = [
box('pheasant', 0.2, 0.1, 0.1, modelId: 1, modelName: '野鸡模型'),
box('pheasant', 0.3, 0.8, 0.8, modelId: 1, modelName: '野鸡模型'),
];
final merged = mergeAcrossModels(all, 0.45);
expect(merged.length, 2);
expect(merged.first.score, 0.3); // 按分排序
});
test('单条结果原样返回', () {
final single = [box('suspect', 0.11, 0.2, 0.2)];
final merged = mergeAcrossModels(single, 0.45);
expect(identical(merged, single), isTrue);
});
}