This commit is contained in:
2026-08-25 16:01:07 +08:00
parent 63b169a033
commit d3ba1c7e48
26 changed files with 1542 additions and 148 deletions
+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
@@ -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"
}
}
@@ -1,5 +1,22 @@
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
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() }
}
override fun onDestroy() {
cameraChannel?.destroy()
super.onDestroy()
}
}
+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!);
}
+121 -40
View File
@@ -1,7 +1,6 @@
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';
@@ -30,17 +29,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 {
@@ -100,6 +170,7 @@ class _CameraScreenState extends State<CameraScreen> {
@override
void dispose() {
_statsTimer?.cancel();
WakelockPlus.disable();
_cameraController?.stop();
_analyzer?.dispose();
@@ -120,29 +191,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 +263,7 @@ class _CameraScreenState extends State<CameraScreen> {
right: 0,
child: _CameraTopBar(
onClose: () => Navigator.of(context).pop(),
onOpenSettings: _openSettings,
),
),
],
@@ -238,13 +305,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 ? '已加载' : '未加载'} 帧:${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 +353,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 +391,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 +402,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 +435,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();
}
+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(
+166 -14
View File
@@ -30,9 +30,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;
@@ -88,20 +89,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],
]);
}
@@ -142,7 +160,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 +169,7 @@ class DetectorWorker {
case 'error':
_inFlight--;
onError?.call(list[1] as String);
break;
}
}
@@ -161,6 +180,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);
@@ -179,6 +205,8 @@ Future<void> _workerMain(SendPort mainPort) async {
TfliteDetector? detector;
MotionDetector? motion;
BackgroundModel? background;
var lastDualMs = 0; // 双字节序推理诊断节流
Uint8List? prevY; // 上一帧 Y/RGBA 平面(帧间 diff 诊断)
await for (final msg in control) {
try {
final list = msg as List;
@@ -212,15 +240,104 @@ 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();
// 帧内容统计(诊断):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 值域与色序,再跑正式推理
if (!isBgra && !d.yuvModeKnown) {
d.decideYuvChroma(
planes: planes,
strides: strides,
width: width,
height: height,
);
}
var results = d.detectRaw(
planes: planes,
strides: strides,
width: width,
height: height,
isBgra: isBgra,
rgbaOrder: rgbaOrder,
);
// 自愈:判定后 1.5s 内无检测且帧可用 → 用实时帧重跑完整判定
// (首帧模糊/暗帧导致启发式猜错时,画面稳定后 oracle 可分胜负)
if (!isBgra && d.yuvModeKnown && !d.yuvRetried &&
results.length <= 1 &&
DateTime.now().millisecondsSinceEpoch - d.yuvDecisionMs > 1500 &&
d.retryDecision(
planes: planes,
strides: strides,
width: width,
height: height,
)) {
results = d.detectRaw(
planes: planes,
strides: strides,
width: width,
height: height,
isBgra: isBgra,
);
}
// 低分野鸡框过视觉先验(颜色/位置),减少户外误报
results = VisualPrior.filter(
results,
@@ -229,6 +346,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,6 +354,28 @@ 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 = d.diagnoseOrder(
planes: planes,
strides: strides,
width: width,
height: height,
isBgra: true,
rgbaOrder: false);
final b = d.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,
@@ -252,14 +392,26 @@ 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] | ${d.yuvDiag}$dualDiag',
]);
break;
case 'reset':
motion?.reset();
background?.reset();
detector?.resetYuvMode();
break;
case 'set-min-score':
detector?.minScore = (list[1] as num).toDouble();
mainPort.send(['log', 'min-score=${detector?.minScore}']);
}
} catch (e) {
mainPort.send(['error', '$e']);
} catch (e, st) {
mainPort.send([
'error',
'$e\n${st.toString().split('\n').take(3).join('\n')}'
]);
}
}
}
+216 -29
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';
@@ -11,8 +12,9 @@ import 'nms.dart';
/// 输入为 NCHW [1, 3, 704, 704]litert 导出保留 torch 布局)。
class TfliteDetector {
static const int inputSize = 704;
// 野鸡数据置信度普遍偏低(0.1~0.2 量级),保留低分池供运动检测提升
static const double minScore = 0.10;
// 野鸡数据置信度普遍偏低(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';
@@ -75,13 +77,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 +107,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 +163,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 +188,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 +267,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 +281,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 +
@@ -308,5 +469,31 @@ class TfliteDetector {
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);
+1 -1
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"
+3 -1
View File
@@ -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
+2 -1
View File
@@ -40,7 +40,7 @@ Flutter App ── POST /auth/register|login ─► 账号注册/登录,签发
| 表 | 说明 | 关键字段 |
|---|---|---|
| `payment_order` | 支付订单 | `order_id`(PK)、`phone_num``plan_id``channel`(wechat/alipay)、`amount_cents``status`(created/paid/closed)、`wx_trade_no`(UNIQUE)、`alipay_trade_no`(UNIQUE)、`created_at``paid_at` |
| `license` | 手机号账号与授权 | `phone_num`(PK)、`password`(bcrypt)、`expires_at`(未充值 NULL)、`created_at``updated_at` |
| `license` | 手机号账号与授权 | `phone_num`(PK)、`password`(bcrypt)、`expires_at`(未充值 NULL)、`remark`(管理端备注)、`created_at``updated_at` |
建表与迁移见 `技术设计.md`(新库直接建表;存量库以 `PRAGMA user_version` 版本化迁移)。
@@ -139,6 +139,7 @@ Flutter App ── POST /auth/register|login ─► 账号注册/登录,签发
| GET | `/admin/licenses` | 账号/授权列表,筛选 `phoneNum` + `page/size`(含未充值账号) |
| POST | `/admin/licenses/grant` | 手动授权 `{"phoneNum":"13800000000","planId":"day"}`,自然日叠加 |
| POST | `/admin/licenses/revoke` | 撤销授权 `{"phoneNum":"13800000000"}`(清授权保留账号) |
| POST | `/admin/licenses/remark` | 写账号备注 `{"phoneNum":"13800000000","remark":"..."}`(空串清空,≤200 字) |
管理页面(订单/授权)由 `server_admin/` 构建产物提供,访问 `http://<host>/admin/`。金额均为整数分,前端展示 ÷100 转元。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>视野管理端</title>
<script type="module" crossorigin src="/admin/assets/index-gy5ysx_E.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-DsTNKZnX.css">
<script type="module" crossorigin src="/admin/assets/index-B_oC4njo.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-BcJw2rv4.css">
</head>
<body>
<div id="app"></div>
+5
View File
@@ -33,3 +33,8 @@ func (c *cAdmin) Grant(ctx context.Context, req *dto.AdminGrantReq) (*dto.AdminG
func (c *cAdmin) Revoke(ctx context.Context, req *dto.AdminRevokeReq) (*dto.AdminRevokeRes, error) {
return service.License.AdminRevoke(ctx, req)
}
// Remark 写账号备注
func (c *cAdmin) Remark(ctx context.Context, req *dto.AdminRemarkReq) (*dto.AdminRemarkRes, error) {
return service.License.AdminRemark(ctx, req)
}
+11
View File
@@ -24,12 +24,14 @@ func init() {
phone_num TEXT PRIMARY KEY,
password TEXT NOT NULL,
expires_at TEXT,
remark TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`)
if err != nil {
panic(err)
}
common.EnsureColumn(ctx, consts.TableLicense, "remark", "remark TEXT")
}
// GetByPhone 按手机号查询(走缓存,键含手机号)
@@ -108,6 +110,15 @@ func (d *licenseDao) ClearAuthInTx(ctx context.Context, tx gdb.TX, phone string)
return err
}
// SetRemarkInTx 事务内写备注(管理端 remark,空串即清空):不覆盖密码/授权字段
func (d *licenseDao) SetRemarkInTx(ctx context.Context, tx gdb.TX, phone, remark string) error {
_, err := g.DB().Model(consts.TableLicense).Ctx(ctx).TX(tx).
Where("phone_num", phone).
Data(gdb.Map{"remark": remark, "updated_at": gtime.Now()}).
Update()
return err
}
// PageByPhone 管理端账号分页查询:phone 模糊筛选,按更新时间倒序;
// 列表不缓存(单点 GetByPhone 仍走缓存,写后清缓存不变)。
func (d *licenseDao) PageByPhone(ctx context.Context, phone string, page, size int) ([]*entity.License, int64, error) {
+11 -1
View File
@@ -48,10 +48,11 @@ type AdminLicenseListReq struct {
Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"`
}
// AdminLicenseItem 账号条目(未充值时 expiresAt 为 null
// AdminLicenseItem 账号条目(未充值时 expiresAt 为 nullremark 为管理端备注
type AdminLicenseItem struct {
PhoneNum string `json:"phoneNum"`
ExpiresAt *gtime.Time `json:"expiresAt"`
Remark string `json:"remark"`
CreatedAt *gtime.Time `json:"createdAt"`
UpdatedAt *gtime.Time `json:"updatedAt"`
}
@@ -79,3 +80,12 @@ type AdminRevokeReq struct {
}
type AdminRevokeRes struct{}
// AdminRemarkReq 写账号备注(空串即清空;仅管理端可见,客户端接口不含该字段)
type AdminRemarkReq struct {
g.Meta `path:"/licenses/remark" method:"post" summary:"写账号备注" tags:"管理端"`
PhoneNum string `json:"phoneNum" v:"required|length:6,20" dc:"手机号"`
Remark string `json:"remark" v:"length:0,200" dc:"备注内容,空串清空"`
}
type AdminRemarkRes struct{}
+1
View File
@@ -8,6 +8,7 @@ type License struct {
PhoneNum string `json:"phoneNum" orm:"phone_num" description:"手机号账号(主键)"`
Password string `json:"-" orm:"password" description:"bcrypt 加盐哈希"`
ExpiresAt *gtime.Time `json:"expiresAt" orm:"expires_at" description:"到期时间(自然日,服务端时区),未充值 NULL"`
Remark string `json:"remark" orm:"remark" description:"管理端备注(客户端不可见)"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"注册时间"`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"`
}
+17 -1
View File
@@ -50,7 +50,8 @@ func (s *licenseService) AdminListLicenses(ctx context.Context, req *dto.AdminLi
for _, lic := range list {
items = append(items, &dto.AdminLicenseItem{
PhoneNum: lic.PhoneNum,
ExpiresAt: lic.ExpiresAt, CreatedAt: lic.CreatedAt, UpdatedAt: lic.UpdatedAt,
ExpiresAt: lic.ExpiresAt, Remark: lic.Remark,
CreatedAt: lic.CreatedAt, UpdatedAt: lic.UpdatedAt,
})
}
return &dto.AdminLicenseListRes{Total: total, List: items}, nil
@@ -102,3 +103,18 @@ func (s *licenseService) AdminRevoke(ctx context.Context, req *dto.AdminRevokeRe
common.ClearCache(ctx, "license:"+req.PhoneNum)
return &dto.AdminRevokeRes{}, nil
}
// AdminRemark 写账号备注(管理端运营记录):空串即清空,不覆盖密码/授权;
// 单写者串行事务内更新,提交后清授权缓存(列表不缓存不受影响)。
func (s *licenseService) AdminRemark(ctx context.Context, req *dto.AdminRemarkReq) (*dto.AdminRemarkRes, error) {
err := common.Serial().Submit(ctx, func() error {
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
return dao.License.SetRemarkInTx(ctx, tx, req.PhoneNum, req.Remark)
})
})
if err != nil {
return nil, err
}
common.ClearCache(ctx, "license:"+req.PhoneNum)
return &dto.AdminRemarkRes{}, nil
}
+18
View File
@@ -52,6 +52,24 @@ func ClearCache(ctx context.Context, keys ...string) {
}
}
// EnsureColumn 存量库迁移:列缺失时 ALTER TABLE ADD COLUMNSQLite 支持表尾追加)。
// 新库由 CREATE TABLE 直接含列、已迁移库列已存在,均跳过;失败 panic(启动即暴露)。
func EnsureColumn(ctx context.Context, table, column, ddl string) {
res, err := g.DB().GetAll(ctx, "PRAGMA table_info("+table+")")
if err != nil {
panic("检查表结构失败 " + table + ": " + err.Error())
}
for _, r := range res {
if gconv.String(r["name"]) == column {
return
}
}
if _, err := g.DB().Exec(ctx, "ALTER TABLE "+table+" ADD COLUMN "+ddl); err != nil {
panic("迁移加列失败 " + table + "." + column + ": " + err.Error())
}
g.Log().Warningf(ctx, "存量表 %s 已迁移:新增列 %s", table, column)
}
// DropLegacyTableIfHasColumn 存量库迁移:表存在旧版本废弃列(账号体系上线前的 device_id)
// 时 DROP 整表(存量数据作废,用户决策),由 dao init 以新结构重建。
func DropLegacyTableIfHasColumn(ctx context.Context, table, column string) {
+2
View File
@@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS license (
phone_num TEXT PRIMARY KEY, -- 手机号账号,一账号一授权记录,续费更新
password TEXT NOT NULL, -- bcrypt 加盐哈希,不存明文
expires_at TEXT, -- 未充值 NULLNULL/过期即未授权)
remark TEXT, -- 管理端备注(运营发卡/人工记录,客户端不可见)
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -59,6 +60,7 @@ CREATE INDEX IF NOT EXISTS idx_payment_order_phone ON payment_order(phone_num);
- v1 = 建表(各 dao init)+ 种子套餐(v1 已移除)
- v2 = 删除 `license.plan_id` 列(该字段无业务语义,只留到期时间;`PRAGMA table_info` 检测列存在才 `ALTER TABLE ... DROP COLUMN`,新库与已迁移库跳过)
- v3 = `DROP TABLE IF EXISTS plan`(套餐改配置后清残留表,订单快照 `payment_order.plan_id` 不受影响)
- v4 = `license``remark` 列(管理端备注;`PRAGMA table_info` 检测列缺失才 `ALTER TABLE ... ADD COLUMN remark TEXT`,新库直接建表跳过)
## 账号体系(注册/登录)
+54 -1
View File
@@ -14,6 +14,10 @@ const grantVisible = ref(false)
const grantForm = reactive({ phoneNum: '', planId: 'day' })
const granting = ref(false)
const remarkVisible = ref(false)
const remarkForm = reactive({ phoneNum: '', remark: '' })
const remarking = ref(false)
const planOptions = [
{ value: 'day', label: '1天' },
{ value: 'week', label: '7天' },
@@ -64,6 +68,29 @@ function submitGrant() {
})
}
function openRemark(row) {
remarkForm.phoneNum = row.phoneNum
remarkForm.remark = row.remark || ''
remarkVisible.value = true
}
function submitRemark() {
remarking.value = true
request
.post('/licenses/remark', {
phoneNum: remarkForm.phoneNum.trim(),
remark: remarkForm.remark.trim(),
})
.then(() => {
ElMessage.success(remarkForm.remark ? '备注已保存' : '备注已清空')
remarkVisible.value = false
load()
})
.finally(() => {
remarking.value = false
})
}
function revoke(row) {
ElMessageBox.confirm(`确认撤销 ${row.phoneNum} 的授权?撤销后该账号额度立即失效,账号保留可继续登录。`, '撤销授权', {
type: 'warning',
@@ -112,9 +139,13 @@ onMounted(load)
<el-table-column prop="updatedAt" label="更新时间" width="180">
<template #default="{ row }">{{ row.updatedAt || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openGrant(row)">授权</el-button>
<el-button type="warning" link size="small" @click="openRemark(row)">备注</el-button>
<el-button type="danger" link size="small" :disabled="!row.expiresAt" @click="revoke(row)">撤销</el-button>
</template>
</el-table-column>
@@ -146,6 +177,28 @@ onMounted(load)
<el-button type="primary" :loading="granting" @click="submitGrant">确认授权</el-button>
</template>
</el-dialog>
<el-dialog v-model="remarkVisible" title="账号备注" width="420px">
<el-form label-width="80px">
<el-form-item label="手机号">
<el-input :model-value="remarkForm.phoneNum" disabled />
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="remarkForm.remark"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="运营备注(如发卡渠道/人工记录),留空提交即清空"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="remarkVisible = false">取消</el-button>
<el-button type="primary" :loading="remarking" @click="submitRemark">保存</el-button>
</template>
</el-dialog>
</el-card>
</template>