diff --git a/flutter_app/README.md b/flutter_app/README.md index 165223e..ae1316d 100644 --- a/flutter_app/README.md +++ b/flutter_app/README.md @@ -62,34 +62,59 @@ GPU delegate 默认允许 FP16 计算(YOLO 类精度损失可忽略);如 ## 模型热更新(多数据集模型) -模型与 APK 更新走**独立通道**:启动时拉取 `GET /api/v1/app/update` 随附的 -`models` 目录(公开接口,无需登录),与 `UpdateChecker` 的 APK 检查并行。 +模型与 APK 更新走**独立通道**:进入视野页时后台同步 `GET /api/v1/app/update` +随附的 `models` 目录(公开接口,无需登录;不阻塞相机预览启动),与 +`UpdateChecker` 的 APK 检查并行。 - **目录条目**:`{datasetId, datasetName, variant, version, labels[], sizeBytes, sha256, - downloadUrl, coverUrl}`——**双档位(2026-09-03)**:每数据集至多 2 条 = 高识别 s + downloadUrl, coverUrl}`——**双档位(2026-09-03)**:每数据集至多 2 条 = 高精度 s (@1280 精度优先,默认)+ 高性能 n(@704 速度优先)各自的当前版本,条目带 `variant` (s/n);服务器未发布模型时不返回 `models` 字段,App 无模型可用, 相机页仅预览不识别。 - **下载入口**:相机页设置弹层「模型清单」按需下载/使用(封面缩略图 2 列网格, - 每数据集两档各占一格并带档位角标;未下载点击「使用」显示进度,完成自动激活; - 已激活再次点击取消;下载中可取消)。**档位切换**:弹层顶部「识别模式」分段控件 - (s 高识别 / n 高性能,默认 s)持久化本地,切换即热加载新档位已激活模型。 + **同一物种合并一张卡**,卡上双档行标注各自版本与状态(使用中绿色高亮);点 + 「下载」一次性补下该动物高精度+高性能两个已训练文件——各档独立进度逐条展示、 + 可取消(已下过的档不重复下),**目标档落地即自动启用、伴档备好**;目标档已 + 启用显示「使用中」点击取消使用。弹层高度上限 70% 屏高(防盖满全屏无法关闭)。 + **档位(2026-09-03 修订)**:同一物种一次只运行一个档位——激活某档会自动停用 + 同物种另一档,**不同物种可用不同档位并行识别**(如雉鸡用高性能、斑鸠用高精度)。 + 弹层顶部「识别模式」分段控件(高性能 / 高精度,默认高精度,持久化本地; + 2026-09-03 高性能移到左位)是**目标档偏好**:不直接切换运行中的模型, + 只决定卡片按钮面向哪个档。卡片主按钮(2026-09-03 简化:不再出现「改用X」、 + 不再显示当前运行档提示):目标档未下载 →「下载」(落地自动启用、伴档备好; + 同一动物另一档在使用时补下目标档后自动切换过去);已下载未启用 →「使用」 + 点击即用(另一档在使用会被自动停用);启用中 →「使用中」点击取消使用。 - **存储**:应用私有目录 `models///`(双档位 2026-09-03, 原无 variant 目录与存量 s 档一致——s 档复用 `models//` 同级读取, 目录键 = 档位标识符),含 `model.tflite`、`labels.json`、`meta.json` (meta 记录 `{version, sha256}`)。版本与摘要都未变化时跳过下载;变化则下载到 `.part` 临时文件、sha256 校验通过后原子 rename 替换,失败重试一次并保留旧模型, 下次启动再试——检查记账按 `(datasetId, variant)` 独立。 +- **目录缓存(2026-09-03)**:最近一次成功拉取的模型目录落盘应用私有目录 + `catalog.json`;每次刷新先载入缓存、展示立即可用(弹层离线/弱网也有内容), + 网络成功后再以权威目录覆盖缓存。清理与自动更新只在网络拉取成功时执行: + 离线降级不清文件、不触发下载——升级后断网首启不会误删已下载模型。 - **清理**:服务器下线的数据集下次同步时删除本地对应目录(两档都无条目时才删)。 -- **并行推理合并**:识别时加载**当前识别档位**下全部已激活模型(`DetectorWorker` - isolate 内逐模型加载,单个失败不影响其他),同帧各模型独立推理后按类别分组做 - **跨模型 NMS**(同类别不同模型检出同一目标取高分去重,不同类别互不压制), - 结果叠加 `modelName`(数据集名+档位)标注来源。 +- **识别会话制(2026-09-03 修订)**:每次进入视野页即开始新识别会话——从 + **仅预览**开始、**不自动恢复上次使用的模型**(上次崩溃/坏模型不会在下一次打开时 + 自动复现,用户总能进入模型清单调整;这也是打开即慢的根因消除项:进入页面不再 + 读模型/重建推理器)。识别需在模型清单**手动启用**,启用状态只在本会话内生效 + (不跨会话持久化);已下载文件常驻本地,「使用」即点即用不重新下载。 + 服务器新版本仅对**已下载文件**后台自动补齐,补齐不改变启用状态。 +- **并行推理合并**:识别时加载**本会话已启用**的全部模型(激活集即实际运行集, + 会话内每数据集至多一个档位;`DetectorWorker` isolate 内逐模型加载,单个失败 + 不影响其他;worker 构建完成后原地挂到已启动的相机流,不重启相机), + 同帧各模型独立推理后做**跨模型 NMS**(所有模型的框统一按 IoU 去重取高分—— + 异类别重叠也压制,实测多个模型会对同一目标检出不同类别),结果叠加 `modelName` + (数据集名)标注来源。 -实现:`lib/models/model_manager.dart`(下载/校验/持久化,`ModelManager` -单例 + ChangeNotifier,条目身份含档位)、`lib/detection/detector_worker.dart` -(多模型并行推理与 `mergeAcrossModels`)、`lib/camera/camera_screen.dart` -(启动同步 + 设置弹层「识别模式」切换 + 诊断行展示模型列表)。 +实现:`lib/models/model_manager.dart`(下载/校验/清单/会话制单档激活收敛, +`ModelManager` 单例 + ChangeNotifier,条目身份含档位)、 +`lib/detection/detector_worker.dart`(多模型并行推理与 `mergeAcrossModels`)、 +`lib/camera/camera_screen.dart`(会话重置从仅预览开始 + 先出预览后台同步 + +设置弹层「识别模式」目标档切换 + 底部状态胶囊:识别延迟)、 +`lib/camera/model_catalog_section.dart`(清单网格:按数据集合并卡片、 +双档下载与「使用/使用中/下载」主按钮动作,2026-09-03 简化文案)。 - **模型输入是 NHWC**:训练导出的模型需做字节级手术(开头 TRANSPOSE→RESHAPE, 输入 [1,320,320,3])再发布给 App,否则 iOS 报 diff --git a/flutter_app/android/app/src/main/AndroidManifest.xml b/flutter_app/android/app/src/main/AndroidManifest.xml index b5cd1d9..0ed652d 100644 --- a/flutter_app/android/app/src/main/AndroidManifest.xml +++ b/flutter_app/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,6 @@ - - - when (call.method) { - "install" -> install(call.argument("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) { - installSettled = false - val file = File(path) - Log.d(TAG, "install: path=$path exists=${file.exists()} len=${file.length()}") - if (!file.exists()) { - result.error("FILE_NOT_FOUND", "APK 文件不存在: $path", null) - return - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && - !activity.packageManager.canRequestPackageInstalls() - ) { - Log.d(TAG, "install: permission_required") - 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 - Log.d(TAG, "install: session=$sessionId opened") - // 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 - Log.d(TAG, "callback onProgressChanged=$progressPercent") - emit("progress", "progress" to progressPercent.toInt()) - } - - override fun onFinished(id: Int, success: Boolean) { - if (id != sessionId) return - Log.d(TAG, "callback onFinished success=$success") - installSettled = true - emit("finished", "success" to success) - pm.packageInstaller.unregisterSessionCallback(this) - activeSession = null - } - } - pm.packageInstaller.registerSessionCallback(callback, Handler(Looper.getMainLooper())) - // 安装前已装版本号(更新安装成功后必然变化,轮询兜底依据) - val installedCode = try { - pm.getPackageInfo(activity.packageName, 0).versionCode - } catch (e: Exception) { - -1 - } - // 写 APK 到会话:1MB 缓冲流式拷贝,完成后 commit 弹系统确认框 - Thread { - try { - Log.d(TAG, "writeThread: start") - 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) - } - } - } - Log.d(TAG, "writeThread: written, commit") - // commit 的 status receiver 必须 MUTABLE:系统要向 intent 写入安装结果, - // FLAG_IMMUTABLE 直接抛 "The commit() status receiver should come from a - // mutable PendingIntent"(模拟器 AOSP 严格校验;部分 ROM 不校验但回调丢失) - val sender = PendingIntent.getActivity( - activity, - 0, - Intent(activity, MainActivity::class.java), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, - ).intentSender - session.commit(sender) - } catch (e: Exception) { - Log.e(TAG, "writeThread: failed", e) - session.abandon() - emit("failed", "error" to (e.message ?: "安装会话写入失败")) - activeSession = null - } - }.start() - // 完成轮询兜底:回调缺失时靠版本号变化判定安装成功(2s 间隔,最长 120s) - Thread { - var waited = 0 - while (!installSettled && waited < 120_000) { - Thread.sleep(2000) - waited += 2000 - val now = try { - pm.getPackageInfo(activity.packageName, 0).versionCode - } catch (e: Exception) { - -1 - } - if (now != installedCode && now != -1) { - Log.d(TAG, "poll: versionCode changed $installedCode->$now, settled") - installSettled = true - emit("finished", "success" to true) - return@Thread - } - } - if (!installSettled) { - Log.d(TAG, "poll: timeout after 120s, not settled") - emit("failed", "error" to "安装超时,请重试") - } - }.start() - result.success("installing") - } catch (e: Exception) { - result.error("INSTALL_FAILED", e.message, null) - } - } - - private fun emit(event: String, vararg pairs: Pair) { - // EventSink.success 内部 dispatchPlatformMessage 要求主线程(@UiThread 校验), - // 写 APK 线程 / 轮询线程直接调用会崩溃,必须投递主线程 - Handler(Looper.getMainLooper()).post { - Log.d(TAG, "emit: $event $pairs") - progressSink?.success(mapOf("event" to event, *pairs)) - } - } - - companion object { - private const val TAG = "InstallerChannel" - } -} diff --git a/flutter_app/android/app/src/main/kotlin/com/example/observer/MainActivity.kt b/flutter_app/android/app/src/main/kotlin/com/example/observer/MainActivity.kt index 47b17ca..84a9242 100644 --- a/flutter_app/android/app/src/main/kotlin/com/example/observer/MainActivity.kt +++ b/flutter_app/android/app/src/main/kotlin/com/example/observer/MainActivity.kt @@ -1,54 +1,10 @@ package com.example.observer -import android.content.Intent -import android.content.pm.PackageInstaller -import android.os.Build -import android.os.Bundle -import android.util.Log import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine class MainActivity : FlutterActivity() { private var cameraChannel: CameraChannel? = null - private var installerChannel: InstallerChannel? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - handleInstallIntent(intent) - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - handleInstallIntent(intent) - } - - /** - * Android 14+ 安装确认回调:PackageInstaller 会话 commit 后如需要用户确认 - * (确认对话框/安装未知来源授权),系统不直接弹 packageinstaller 界面, - * 而是经 commit 传入的 statusReceiver(指向本 Activity 的 PendingIntent) - * 回传 STATUS_PENDING_USER_ACTION,并在 EXTRA_INTENT 里附上确认界面 Intent - * (ACTION_CONFIRM_INSTALL → InstallStart),由调用方负责启动。 - * 不处理则确认框永不出现,安装卡在 0.8% 直至超时(AOSP 见 - * PackageInstallerSession.sendOnUserActionRequired)。 - */ - private fun handleInstallIntent(intent: Intent?) { - val status = intent?.getIntExtra(PackageInstaller.EXTRA_STATUS, -1) ?: return - if (status != PackageInstaller.STATUS_PENDING_USER_ACTION) return - val confirm = if (Build.VERSION.SDK_INT >= 33) { - intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) - } else { - @Suppress("DEPRECATION") - intent.getParcelableExtra(Intent.EXTRA_INTENT) - } - if (confirm == null) return - try { - confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(confirm) - Log.d(TAG, "handleInstallIntent: started confirm ${confirm.component}") - } catch (e: Exception) { - Log.e(TAG, "handleInstallIntent: start confirm failed", e) - } - } override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) @@ -57,12 +13,6 @@ class MainActivity : FlutterActivity() { // configureFlutterEngine(onCreate 阶段)只注册通道与 viewFactory, // 实际 bindToLifecycle 由 Flutter 相机页 start 时触发(此时已 RESUMED) cameraChannel = CameraChannel(this, flutterEngine).also { it.register() } - // App 内更新安装 APK(PackageInstaller 会话安装 + 进度回传) - installerChannel = InstallerChannel(this, flutterEngine).also { it.register() } - } - - companion object { - private const val TAG = "MainActivity" } override fun onDestroy() { diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index dc9ada4..e8f3e09 100644 Binary files a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 7353c41..bfe8084 100644 Binary files a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 797d452..2edac9f 100644 Binary files a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 6ed2d93..6dde329 100644 Binary files a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 4cd7b00..abd94da 100644 Binary files a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index fe73094..a7f3ed4 100644 Binary files a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 321773c..6db332c 100644 Binary files a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 797d452..2edac9f 100644 Binary files a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 502f463..7baf8f8 100644 Binary files a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0ec3034..3a9af87 100644 Binary files a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0ec3034..3a9af87 100644 Binary files a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e9f5fea..730150f 100644 Binary files a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 84ac32a..25455a5 100644 Binary files a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8953cba..11657ee 100644 Binary files a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 0467bf1..44867f3 100644 Binary files a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/flutter_app/lib/app.dart b/flutter_app/lib/app.dart index 37ee93f..8836975 100644 --- a/flutter_app/lib/app.dart +++ b/flutter_app/lib/app.dart @@ -70,24 +70,19 @@ class _StartupGateState extends State { Navigator.of(context).pushReplacementNamed('/terms'); return; } - // 版本更新检查(公开接口,无需登录态;仅 Android):服务器版本高于 - // 「已装版本与已确认接受版本」中的较大者即强制更新,弹全屏阻塞页。 - // APK 版本号不递增时,点过「立即更新」的 accepted 版本参与比较, - // 更新完成后再次启动不反复提示。 + // 版本更新检查(公开接口,无需登录态;仅 Android):服务器版本高于已装 + // 版本即强制更新,弹全屏阻塞页 → 浏览器下载 APK 手动安装 + // (2026-09-03:App 内安装在小米等 ROM 失败,改浏览器下载) final info = await UpdateChecker().fetch(); final current = await PackageInfo.fromPlatform(); if (!mounted) return; final session = context.read(); - final acceptedUpdate = await session.readAcceptedUpdateVersion(); - if (!mounted) return; - if (UpdateChecker.needsUpdate(info.version, current.version, acceptedUpdate)) { + if (UpdateChecker.needsUpdate(info.version, current.version)) { Navigator.of(context).pushReplacement(MaterialPageRoute( builder: (_) => UpdateScreen( version: info.version, url: UpdateChecker.downloadUrl(), notes: info.notes, - onUpdateAccepted: () => - session.saveAcceptedUpdateVersion(info.version), ), )); return; diff --git a/flutter_app/lib/auth/session_store.dart b/flutter_app/lib/auth/session_store.dart index 8df7bad..9c025ef 100644 --- a/flutter_app/lib/auth/session_store.dart +++ b/flutter_app/lib/auth/session_store.dart @@ -2,17 +2,13 @@ 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 readToken() async { if (_cachedToken != null) return _cachedToken; @@ -24,18 +20,6 @@ class SessionStore { return _cachedPhone = await _storage.read(key: _phoneKey); } - /// 用户点过「立即更新」的服务器版本号(空串 = 从未接受过更新提示) - Future readAcceptedUpdateVersion() async { - if (_cachedAcceptedUpdate != null) return _cachedAcceptedUpdate!; - return _cachedAcceptedUpdate = - (await _storage.read(key: _acceptedUpdateKey)) ?? ''; - } - - Future saveAcceptedUpdateVersion(String version) async { - _cachedAcceptedUpdate = version; - await _storage.write(key: _acceptedUpdateKey, value: version); - } - Future save(String phone, String token) async { _cachedPhone = phone; _cachedToken = token; diff --git a/flutter_app/lib/camera/camera_screen.dart b/flutter_app/lib/camera/camera_screen.dart index df8aad8..390086a 100644 --- a/flutter_app/lib/camera/camera_screen.dart +++ b/flutter_app/lib/camera/camera_screen.dart @@ -47,6 +47,79 @@ class _CameraScreenState extends State { /// ModelManager revision 快照:模型文件更新(自动更新下载新版)也需重建 worker int _lastRevision = -1; + /// C 端状态胶囊:3 秒内连点 5 次切换完整开发诊断(远程收截图报障仍可取数) + bool _devInfo = false; + final List _devTaps = []; + + /// 推理 worker 后台重建中(启用/停用模型触发):期间 modelReady 仍为 false, + /// 胶囊与顶部横幅显示「识别准备中」,不误报「模型加载失败」 + bool _workerPending = false; + + void _onDevTaps() { + final now = DateTime.now(); + _devTaps.add(now); + _devTaps + .removeWhere((t) => now.difference(t) > const Duration(seconds: 3)); + if (_devTaps.length < 5) return; + _devTaps.clear(); + setState(() => _devInfo = !_devInfo); + } + + /// 识别延迟分档(C 端直观读法:流畅 / 一般 / 偏慢) + ({String label, Color color}) _latencyBucket(double avgMs) { + if (avgMs <= 200) return (label: '流畅', color: Colors.greenAccent); + if (avgMs <= 600) return (label: '一般', color: Colors.amberAccent); + return (label: '偏慢', color: Colors.redAccent); + } + + /// C 端底部状态胶囊内容:设备识别延迟(近帧滚动平均)+ 当前识别模型 + List _statusCapsuleLines(CameraUiState s) { + if (!s.modelReady) { + if (_workerPending) { + // worker 后台构建中(用户刚启用模型):给个进行时状态,别误读为未启用 + return const [ + Text( + '识别准备中…', + style: TextStyle(color: Colors.white70, fontSize: 13), + ), + ]; + } + return const [ + Text( + '仅预览 · 未启用识别', + style: TextStyle(color: Colors.orangeAccent, fontSize: 12), + ), + ]; + } + final avg = s.latencyAvgMs; + final lines = []; + if (avg == null) { + lines.add(const Text( + '识别延迟 --', + style: TextStyle(color: Colors.white54, fontSize: 13), + )); + } else { + final b = _latencyBucket(avg); + lines.add(Text( + '识别延迟 ${(avg / 1000).toStringAsFixed(2)} 秒 · ${b.label}', + style: TextStyle( + color: b.color, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + )); + } + final names = ModelManager.instance.modelsLabel; + if (names.isNotEmpty && names != '未下载') { + lines.add(Text( + '识别模型:$names', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white54, fontSize: 10.5), + )); + } + return lines; + } + void _openSettings() { final vm = _viewModel; if (vm == null) return; @@ -56,53 +129,66 @@ class _CameraScreenState extends State { isScrollControlled: true, backgroundColor: Colors.black87, builder: (ctx) => StatefulBuilder( - builder: (ctx, setSheetState) => SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('识别设置', + // 弹层高度 ≤ 70% 屏高:超过会盖满全屏使遮罩无处点按,弹层无法关闭 + builder: (ctx, setSheetState) => ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(ctx).size.height * 0.7, + ), + child: SingleChildScrollView( + child: 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)}%', + 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( - '阈值越低识别越灵敏(低分框越多,误报也可能增加)', - style: TextStyle(color: Colors.white54, fontSize: 12), - ), - const SizedBox(height: 16), - const Divider(color: Colors.white12), - const SizedBox(height: 8), - ModelCatalogSection(manager: ModelManager.instance), - ], + 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( + '阈值越低识别越灵敏(低分框越多,误报也可能增加)', + style: TextStyle(color: Colors.white54, fontSize: 12), + ), + const SizedBox(height: 16), + const Divider(color: Colors.white12), + const SizedBox(height: 8), + ModelCatalogSection(manager: ModelManager.instance), + ], + ), ), ), ), @@ -115,15 +201,20 @@ class _CameraScreenState extends State { super.initState(); 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')}'); + 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()); + _statsTimer = Timer.periodic( + const Duration(seconds: 1), + (_) => _pollStats(), + ); // 模型清单激活集变化(下载完成自动激活/取消激活)时重建推理 worker ModelManager.instance.addListener(_onModelsChanged); } @@ -142,29 +233,37 @@ class _CameraScreenState extends State { setState(() => _permissionGranted = granted); if (!granted) return; - // 模型热更新:启动拉取目录(只拉不下载),兜底等待;已下载/已激活模型 - // 有新版本时 autoUpdate 自动重下,变化经 _onModelsChanged 重建 worker - try { - await ModelManager.instance - .refresh() - .timeout(const Duration(seconds: 30)); - } catch (_) {} - await _reloadWorker(); + // 新识别会话(2026-09-03):不恢复上次启用的模型——每次进入都从仅预览开始, + // 识别需用户在模型清单手动启用;崩溃/坏模型不会自动复现。激活集变化经 + // _onModelsChanged 重建 worker 挂载 + ModelManager.instance.resetForSession(); + // 目录同步先行(缓存优先,2026-09-03):进页面立即触发——refresh 开头先把 + // 上次成功目录从磁盘载入并 notify,设置弹层打开即有内容(不完全依赖网络); + // 网络拉取/自动更新在后台继续,全程不阻塞预览 + unawaited(ModelManager.instance.refresh()); + // 先出预览:占位 analyzer(无 worker)让相机立即启动,识别加载完再原地挂上 + if (_analyzer == null) { + final vm = CameraViewModel(reminder: Reminder()); + _viewModel = vm; + _analyzer = FrameAnalyzer(worker: null, viewModel: vm); + } await _startCamera(); } - /// 用当前激活模型重建推理 worker(激活集变化/启动时调用); - /// worker 为 null(无激活模型或加载失败)时仅预览并提示。 - /// 并发调用共享同一进行中的重建:refresh 通知触发的重建与 _init 的等待 - /// 共用一个 Future,相机等重建完成后再启动(避免绑定旧 analyzer)。 + /// 用当前激活模型重建推理 worker(启用/停用模型、模型文件更新时调用); + /// worker 为 null(无启用模型或加载失败)时仅预览并提示。 + /// 并发调用共享同一进行中的重建:进行中时先等完成再复查,期间变化不丢失。 Future _reloadWorker() { final inFlight = _reloadInFlight; if (inFlight != null) { // 重建进行中:完成后按最新状态复查,期间的变化不丢失 return inFlight.then((_) => _reloadWorker()).catchError((_) {}); } - _reloadInFlight = - _doReloadWorker().whenComplete(() => _reloadInFlight = null); + if (mounted) setState(() => _workerPending = true); + _reloadInFlight = _doReloadWorker().whenComplete(() { + _reloadInFlight = null; + if (mounted) setState(() => _workerPending = false); + }); return _reloadInFlight!; } @@ -182,24 +281,31 @@ class _CameraScreenState extends State { _lastRevision = mgr.revision; final worker = await DetectorWorker.create(models: models); _loadedModelIds = ids; + if (!mounted) { + worker?.dispose(); + return; // 页面已关闭:不触碰可能已 dispose 的 vm/analyzer + } final vm = _viewModel ?? CameraViewModel(reminder: Reminder()); vm.setModelReady(worker != null); - final analyzer = FrameAnalyzer(worker: worker, viewModel: vm); - final old = _analyzer; - if (!mounted) { - analyzer.dispose(); - if (vm != _viewModel) vm.dispose(); + // 重建后把设置页调过的阈值落到新 worker(避免静默回到默认值) + worker?.setMinScore(_minScore); + final existing = _analyzer; + if (existing == null) { + // 相机尚未绑定 analyzer(冷启动竞态兜底):直接以新 worker 建 analyzer + final analyzer = FrameAnalyzer(worker: worker, viewModel: vm); + setState(() { + _viewModel = vm; + _analyzer = analyzer; + }); + if (_cameraController != null) { + await _cameraController!.start(analyzer); + } return; } - setState(() { - _viewModel = vm; - _analyzer = analyzer; - }); - old?.worker?.dispose(); - // 相机已启动:重启帧流绑定新 analyzer(start 内部先 stop 再订阅) - if (mounted && _cameraController != null) { - await _cameraController!.start(analyzer); - } + // 相机已绑定(预览中):worker 原地挂到既有 analyzer——帧流回调闭包捕获的 + // 是 analyzer 对象本身,替换其 worker 双平台即时生效;重启相机会重新 + // initialize(iOS ~1s+)且预览闪断,一律避免(2026-09-03) + existing.attachWorker(worker); } void _onModelsChanged() { @@ -310,7 +416,10 @@ class _CameraScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Text('相机初始化失败', style: TextStyle(color: Colors.white)), + const Text( + '相机初始化失败', + style: TextStyle(color: Colors.white), + ), if (_initError != null) Padding( padding: const EdgeInsets.only(top: 8), @@ -320,7 +429,9 @@ class _CameraScreenState extends State { overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: const TextStyle( - color: Colors.redAccent, fontSize: 11), + color: Colors.redAccent, + fontSize: 11, + ), ), ), TextButton( @@ -361,8 +472,7 @@ class _CameraScreenState extends State { right: 16, top: MediaQuery.of(context).padding.top + 56, child: Container( - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: Colors.black54, borderRadius: BorderRadius.circular(8), @@ -375,8 +485,9 @@ class _CameraScreenState extends State { ), ), - // 模型未加载时仅显示相机预览,不做检测标注(横幅置于顶栏下方,避免与底部诊断行重叠) - if (!vm.state.modelReady) + // 模型未加载时仅显示相机预览,不做检测标注(横幅置于顶栏下方,避免与底部诊断行重叠); + // worker 后台构建中不提示「加载失败」(modelReady 就绪前的一瞬) + if (!vm.state.modelReady && !_workerPending) Positioned( left: 16, right: 16, @@ -405,56 +516,87 @@ class _CameraScreenState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text( - '阈值:${(_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), + // C 端状态胶囊:识别延迟 + 识别模型(连点 5 次展开完整开发诊断) + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _onDevTaps, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: _statusCapsuleLines(vm.state), + ), + ), ), - 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 (_devInfo) ...[ + const SizedBox(height: 4), + const Text( + '开发诊断(连点上方状态胶囊 5 次收起)', + style: TextStyle(color: Colors.white24, fontSize: 10), ), - if (vm.state.debugYuv.isNotEmpty) Text( - 'yuv:${vm.state.debugYuv}', - style: const TextStyle( - color: Colors.yellowAccent, fontSize: 11), - ), - if (camera != null) - Text( - 'streaming:${camera.isStreaming} ' - 'camErr:${camera.errorDescription ?? '无'}', - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.cyanAccent, fontSize: 11), - ), - if (vm.state.debugLastError != null) - Text( - vm.state.debugLastError!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.redAccent, fontSize: 11), - ), - if (_globalError != null) - Text( - _globalError!, - maxLines: 3, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.redAccent, fontSize: 11), + '阈值:${(_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.latencyAvgMs?.toStringAsFixed(0) ?? '-'}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.isStreaming} ' + 'camErr:${camera.errorDescription ?? '无'}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.cyanAccent, + fontSize: 11, + ), + ), + if (vm.state.debugLastError != null) + Text( + vm.state.debugLastError!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: + const TextStyle(color: Colors.redAccent, fontSize: 11), + ), + if (_globalError != null) + Text( + _globalError!, + maxLines: 3, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: + const TextStyle(color: Colors.redAccent, fontSize: 11), + ), + ], ], ), ), ], ); } - } /// 双指捏合缩放预览;overlay 与纹理同几何(Stack 内同尺寸) @@ -464,10 +606,7 @@ class _ZoomablePreview extends StatefulWidget { /// 检测框 overlay(随帧更新,与纹理同区域) final Widget? overlay; - const _ZoomablePreview({ - required this.controller, - this.overlay, - }); + const _ZoomablePreview({required this.controller, this.overlay}); @override State<_ZoomablePreview> createState() => _ZoomablePreviewState(); @@ -497,8 +636,7 @@ class _ZoomablePreviewState extends State<_ZoomablePreview> { return GestureDetector( onScaleStart: (_) => _gestureStartZoom = _currentZoom, onScaleUpdate: (d) { - final target = - (_gestureStartZoom * d.scale).clamp(_minZoom, _maxZoom); + final target = (_gestureStartZoom * d.scale).clamp(_minZoom, _maxZoom); if ((target - _currentZoom).abs() < 0.01) return; _currentZoom = target; widget.controller.setZoomLevel(target); @@ -518,10 +656,7 @@ class _CameraTopBar extends StatelessWidget { final VoidCallback onClose; final VoidCallback onOpenSettings; - const _CameraTopBar({ - required this.onClose, - required this.onOpenSettings, - }); + const _CameraTopBar({required this.onClose, required this.onOpenSettings}); @override Widget build(BuildContext context) { @@ -558,10 +693,7 @@ class _PermissionGuide extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Text( - '需要相机权限才能进行实时识别', - style: TextStyle(color: Colors.white), - ), + const Text('需要相机权限才能进行实时识别', style: TextStyle(color: Colors.white)), const SizedBox(height: 16), FilledButton(onPressed: onRequest, child: const Text('授权相机')), ], diff --git a/flutter_app/lib/camera/camera_view_model.dart b/flutter_app/lib/camera/camera_view_model.dart index 747b4a6..43985b7 100644 --- a/flutter_app/lib/camera/camera_view_model.dart +++ b/flutter_app/lib/camera/camera_view_model.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import '../detection/detection_result.dart'; import '../detection/motion_aggregator.dart'; +import '../detection/nms.dart'; import '../reminder/reminder.dart'; @immutable @@ -21,6 +22,10 @@ class CameraUiState { final int debugLastMs; final String debugYuv; + /// 近 [CameraViewModel.latencyWindow] 帧推理耗时滚动平均(毫秒;无样本为 null)。 + /// 给 C 端用户看的设备识别延迟。 + final double? latencyAvgMs; + const CameraUiState({ this.modelReady = false, this.results = const [], @@ -34,6 +39,7 @@ class CameraUiState { this.framesReceived = 0, this.debugLastMs = 0, this.debugYuv = '', + this.latencyAvgMs, }); } @@ -43,6 +49,9 @@ class CameraUiState { /// 高于 0.35 视为强证据。 /// - 低于 0.35 的框:需要多帧稳定([confirmFrames] 帧)或 活动证据 /// (运动区域/背景新出现区域重叠)才确认显示。 +/// - 同位置去重(2026-09-03):多模型对同一目标交替检出时各轨迹都会在 +/// 忘记窗内持续显示 → 轨迹层跨标签同目标补挂 + 显示层 +/// [dedupeVisibleOverlaps] 同类别强重叠只保留高置信度框。 class CameraViewModel extends ChangeNotifier { static const int maxTracks = 30; static const double motionBoost = 0.15; @@ -52,6 +61,10 @@ class CameraViewModel extends ChangeNotifier { static const int displayAgeMs = 500; static const int forgetMs = 2000; + /// 延迟滚动平均窗口(帧数):单帧抖动大,取近期均值给用户展示 + static const int latencyWindow = 30; + final List _procWindow = []; + /// 推理后台 isolate 是否就绪(由相机页创建 worker 后设置) bool modelReady = false; @@ -68,6 +81,8 @@ class CameraViewModel extends ChangeNotifier { void setModelReady(bool ready) { if (modelReady == ready) return; modelReady = ready; + // 换 worker / 停识别:旧窗口样本作废,从零起算 + _procWindow.clear(); _state = CameraUiState(modelReady: ready); notifyListeners(); } @@ -122,9 +137,18 @@ class CameraViewModel extends ChangeNotifier { for (final r in results) { if (r.score > highest) highest = r.score; } + if (lastProcessMs > 0) { + _procWindow.add(lastProcessMs); + if (_procWindow.length > latencyWindow) { + _procWindow.removeAt(0); + } + } + final latencyAvg = _procWindow.isEmpty + ? null + : _procWindow.reduce((a, b) => a + b) / _procWindow.length; _state = CameraUiState( modelReady: modelReady, - results: visible, + results: dedupeVisibleOverlaps(visible), rotation: rotation, imageWidthPx: imageWidthPx, imageHeightPx: imageHeightPx, @@ -135,6 +159,7 @@ class CameraViewModel extends ChangeNotifier { framesReceived: framesReceived, debugLastMs: lastProcessMs, debugYuv: yuvDiag.isNotEmpty ? yuvDiag : _state.debugYuv, + latencyAvgMs: latencyAvg, ); notifyListeners(); } @@ -161,6 +186,24 @@ class CameraViewModel extends ChangeNotifier { best = t; } } + // 跨标签同目标补挂(2026-09-03 用户实测修订:重叠位置只保留高置信度框): + // 中心距超过跨标签收紧半径、但几何强重叠指向同一位置(不同模型对同一 + // 目标的框偏移/紧致度不同)的检测并入既有轨迹,防止同目标两条轨迹并存 + // → 同位置双名常驻/重复提醒。疑似↔目标(不同类别预警)不并入。 + if (best == null) { + _Track? adopt; + var adoptD = double.infinity; + for (final t in _tracks.values) { + if (matched.contains(t.id)) continue; + if (t.isSuspect != r.isSuspect || !sameTarget(t.result, r)) continue; + final d = _centerDist(t.result, r); + if (d < adoptD) { + adoptD = d; + adopt = t; + } + } + best = adopt; + } if (best != null) { matched.add(best.id); best.update(r, now); @@ -234,8 +277,30 @@ class _Track { : lastSeenMs = firstSeenMs, label = result.label; + /// 当前框是否疑似类别(随关联的最新检测更新——轨迹框在跨标签补挂后 + /// 会换成其他标签的框;轨迹 label 仅创建时记账) + bool get isSuspect => result.isSuspect; + void update(DetectionResult r, int now) { lastSeenMs = now; result = r; } } + +/// 同屏抑制(2026-09-03 用户实测修订:同一位置/重叠位置出现同/异模型检出的 +/// 物种只保留高置信度者)。根因:多个模型对同一目标**交替**检出时,每帧合并 +/// 层只压掉当帧低分框,但各自轨迹都落在 2s 忘记窗内持续显示 → 同位置双名 +/// 常驻。显示层兜底:可见框内同类别(都目标/都疑似)、且 [sameTarget] 强 +/// 重叠指向同一位置的框每帧只保留最高分者。异类别(目标×疑似生境预警)是 +/// 两种语义不同的框,同位置也各自保留。 +List dedupeVisibleOverlaps(List visible) { + if (visible.length <= 1) return visible; + final sorted = [...visible]..sort((a, b) => b.score.compareTo(a.score)); + final kept = []; + for (final r in sorted) { + if (!kept.any((k) => k.isSuspect == r.isSuspect && sameTarget(k, r))) { + kept.add(r); + } + } + return kept; +} diff --git a/flutter_app/lib/camera/frame_analyzer.dart b/flutter_app/lib/camera/frame_analyzer.dart index caca13d..82a84e6 100644 --- a/flutter_app/lib/camera/frame_analyzer.dart +++ b/flutter_app/lib/camera/frame_analyzer.dart @@ -12,8 +12,12 @@ class FrameAnalyzer { /// 连续检测:100ms 一帧 int intervalMs = 100; - /// null = 模型加载失败,仅预览不分析 - final DetectorWorker? worker; + DetectorWorker? _worker; + + /// 当前推理 worker(null = 仅预览不分析);相机启动后可用 [attachWorker] + /// 原地替换(相机帧流回调闭包捕获的是本 analyzer 对象,无需重启相机) + DetectorWorker? get worker => _worker; + final CameraViewModel viewModel; int _lastDetectMs = 0; @@ -29,9 +33,21 @@ class FrameAnalyzer { /// 图像流回调是否到达(诊断用) int framesReceived = 0; - FrameAnalyzer({required this.worker, required this.viewModel}) { - worker?.onResult = _onResult; - worker?.onError = _onError; + FrameAnalyzer({DetectorWorker? worker, required this.viewModel}) { + attachWorker(worker); + } + + /// 替换推理 worker(null = 停识别仅预览)。旧 worker 在此释放;若相机已 + /// 启动则新 worker 立即接管后续帧——重启相机会重新 initialize + /// (iOS ~1s+)且预览闪断,原地替换即时生效(2026-09-03)。 + void attachWorker(DetectorWorker? w) { + if (identical(_worker, w)) return; + final old = _worker; + _worker = w; + w?.onResult = _onResult; + w?.onError = _onError; + old?.dispose(); + _lastDetectMs = 0; } void recordStreamError(String msg) { @@ -127,5 +143,5 @@ class FrameAnalyzer { void reset() => _lastDetectMs = 0; - void dispose() => worker?.dispose(); + void dispose() => _worker?.dispose(); } diff --git a/flutter_app/lib/camera/model_catalog_section.dart b/flutter_app/lib/camera/model_catalog_section.dart index a1ed08c..3cc5139 100644 --- a/flutter_app/lib/camera/model_catalog_section.dart +++ b/flutter_app/lib/camera/model_catalog_section.dart @@ -2,15 +2,24 @@ import 'package:flutter/material.dart'; import '../models/model_manager.dart'; -/// 设置弹层「模型清单」区块:顶部「识别模式」分段控件(s 高识别 / n 高性能, -/// 默认 s,持久化本地;切换即热加载该档位已激活模型),下方 2 列封面缩略图网格。 -/// 双档位:每数据集至多两张卡片(s/n 各一),卡片带档位角标; -/// 卡片状态(下载进度/激活)按 (数据集, 档位) 独立记账。 +/// 档位展示名(档位码 s/n 仅内部记账,不对用户展示) +String _variantLabel(String v) => v == kVariantN ? '高性能' : '高精度'; + +/// 设置弹层「模型清单」区块:顶部「识别模式」分段控件(高性能 / 高精度, +/// 默认高精度,持久化本地;2026-09-03 高性能移左位)选择**目标档位**—— +/// 只记录偏好,不直接切换运行中的模型,而是决定卡片按钮面向哪个档;下方 +/// 2 列封面缩略图网格,同一物种(datasetId)合并一张卡(s/n 两档内部记账, +/// 各自下载独立进度)。 /// -/// 卡片状态机(档位 == 当前识别档位时与原单档行为一致): -/// - 未下载 →「使用/下载」:下载完成自动激活(当前档立即使用,非当前档备好待切); -/// - 已下载未激活 →「使用」直接激活;已激活且当前档 →「已使用」点击取消; -/// - 已激活但属非当前档 →「已备好」(已下载就绪,点击切换到该档立即生效)。 +/// 同一物种一次只运行一个档位:启用某档会自动停用同物种另一档(不同物种可用 +/// 不同档位并行识别)。物种卡片主按钮(面向目标档,2026-09-03 简化文案—— +/// 一律「使用/使用中/下载」,不再叫「改用X」,不显示当前运行档提示): +/// - 任一档下载中 → 逐档进度条 + 取消(中止全部进行中的下载); +/// - 任一档失败 → 错误提示 + 重试(只补下未成功的档); +/// - 目标档已激活 →「使用中」点击取消使用; +/// - 目标档已下载未激活 →「使用」直接启用(另一档在运行会被自动停用); +/// - 目标档未下载 →「下载」取回缺失档(目标档落地自动启用、伴档备好; +/// 另一档在使用时补下目标档后自动切换过去)。 class ModelCatalogSection extends StatelessWidget { final ModelManager manager; @@ -21,44 +30,57 @@ class ModelCatalogSection extends StatelessWidget { return ListenableBuilder( listenable: manager, builder: (context, _) { - // 目录行按 (数据集, 档位) 稳定排序:同数据集 s 前 n 后相邻展示 - final items = [...manager.catalog]..sort((a, b) { - if (a.datasetId != b.datasetId) { - return a.datasetId.compareTo(b.datasetId); - } - return a.variant.compareTo(b.variant); - }); + // 目录按数据集分组:同一物种 s/n 合成一张卡;组内 s(高精度)前 n 后 + final byDataset = >{}; + for (final c in manager.catalog) { + byDataset.putIfAbsent(c.datasetId, () => []).add(c); + } + final groups = byDataset.values.toList() + ..sort((a, b) => a.first.datasetId.compareTo(b.first.datasetId)); + final order = {kVariantS: 0, kVariantN: 1}; + for (final g in groups) { + g.sort((a, b) => + (order[a.variant] ?? 9).compareTo(order[b.variant] ?? 9)); + } return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ - const Text('识别模式', - style: TextStyle(color: Colors.white70, fontSize: 14)), + const Text( + '识别模式', + style: TextStyle(color: Colors.white70, fontSize: 14), + ), const Spacer(), _ModeToggle(manager: manager), ], ), const SizedBox(height: 2), - const Text('切换即加载该档位已激活模型(激活状态跨档保留)', - style: TextStyle(color: Colors.white38, fontSize: 11)), + const Text( + '卡片操作面向所选档位;同一动物一次只运行一档,切换会自动停用另一档', + style: TextStyle(color: Colors.white38, fontSize: 11), + ), const SizedBox(height: 12), Row( children: [ - const Text('模型清单', - style: TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.bold)), + const Text( + '模型清单', + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), const Spacer(), TextButton.icon( onPressed: () => manager.refresh(), icon: const Icon(Icons.refresh, size: 16), label: const Text('刷新'), style: TextButton.styleFrom( - foregroundColor: Colors.white70, - visualDensity: VisualDensity.compact), + foregroundColor: Colors.white70, + visualDensity: VisualDensity.compact, + ), ), ], ), @@ -66,27 +88,29 @@ class ModelCatalogSection extends StatelessWidget { if (manager.error != null) Padding( padding: const EdgeInsets.only(bottom: 8), - child: Text(manager.error!, - style: const TextStyle( - color: Colors.orange, fontSize: 12)), + child: Text( + manager.error!, + style: const TextStyle(color: Colors.orange, fontSize: 12), + ), ), - if (items.isEmpty) - const Text('暂无已发布模型', - style: TextStyle(color: Colors.white54, fontSize: 13)) + if (groups.isEmpty) + const Text( + '暂无已发布模型', + style: TextStyle(color: Colors.white54, fontSize: 13), + ) else GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, mainAxisSpacing: 12, crossAxisSpacing: 12, childAspectRatio: 0.72, ), - itemCount: items.length, + itemCount: groups.length, itemBuilder: (context, i) => - _ModelCard(item: items[i], manager: manager), + _SpeciesCard(items: groups[i], manager: manager), ), ], ); @@ -95,12 +119,16 @@ class ModelCatalogSection extends StatelessWidget { } } -/// 档位分段控件:s 高识别 / n 高性能 +/// 目标档位分段控件:高性能 / 高精度(2026-09-03 调换两档显示位置, +/// 高性能在左、高精度在右;档位码 s/n 仅内部使用,不对用户展示) class _ModeToggle extends StatelessWidget { final ModelManager manager; const _ModeToggle({required this.manager}); + /// 展示顺序(与内部档位常量解耦) + static const List _displayOrder = [kVariantN, kVariantS]; + @override Widget build(BuildContext context) { return Container( @@ -112,9 +140,9 @@ class _ModeToggle extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - for (final v in const [kVariantS, kVariantN]) ...[ - if (v != kVariantS) const SizedBox(width: 2), - _seg(v), + for (var i = 0; i < _displayOrder.length; i++) ...[ + if (i > 0) const SizedBox(width: 2), + _seg(_displayOrder[i]), ], ], ), @@ -123,7 +151,6 @@ class _ModeToggle extends StatelessWidget { Widget _seg(String v) { final selected = manager.mode == v; - final label = v == kVariantS ? 's 高识别' : 'n 高性能'; return InkWell( borderRadius: BorderRadius.circular(6), onTap: () => manager.setMode(v), @@ -134,7 +161,7 @@ class _ModeToggle extends StatelessWidget { borderRadius: BorderRadius.circular(6), ), child: Text( - label, + _variantLabel(v), style: TextStyle( color: selected ? Colors.black : Colors.white70, fontSize: 12, @@ -146,22 +173,35 @@ class _ModeToggle extends StatelessWidget { } } -class _ModelCard extends StatelessWidget { - final ModelCatalogItem item; +/// 单物种卡片:同数据集 s/n 两档合并;下载/激活状态按 (数据集, 档位) 独立记账, +/// 同物种同时至多一个档位被激活(激活以目标档为准) +class _SpeciesCard extends StatelessWidget { + final List items; final ModelManager manager; - const _ModelCard({required this.item, required this.manager}); + const _SpeciesCard({required this.items, required this.manager}); @override Widget build(BuildContext context) { - final variant = item.variant; - final sameMode = manager.mode == variant; - final active = manager.isActive(item.datasetId, variant); - final downloaded = manager.isDownloaded(item.datasetId, variant); - final progress = manager.progressOf(item.datasetId, variant); - final error = manager.errorOf(item.datasetId, variant); - final accent = - variant == kVariantS ? Colors.greenAccent : Colors.orangeAccent; + // 目标条目:当前目标档优先;目录缺目标档(存量单档物种)时取 s 档 + final target = items.firstWhere( + (i) => i.variant == manager.mode, + orElse: () => items.first, + ); + final tActive = manager.isActive(target.datasetId, target.variant); + final tDownloaded = manager.isDownloaded(target.datasetId, target.variant); + // 同物种另一档正在使用的条目(至多一个——每数据集单档激活约束) + final activeOther = items + .where((i) => + i.variant != target.variant && + manager.isActive(i.datasetId, i.variant)) + .toList(); + + // 任一档进行中/失败标志 + final downloading = + items.any((i) => manager.progressOf(i.datasetId, i.variant) != null); + final hasError = + items.any((i) => manager.errorOf(i.datasetId, i.variant) != null); final thumb = ClipRRect( borderRadius: BorderRadius.circular(8), @@ -171,41 +211,25 @@ class _ModelCard extends StatelessWidget { fit: StackFit.expand, children: [ Image.network( - '${manager.baseUrl}${item.coverUrl}', + '${manager.baseUrl}${target.coverUrl}', fit: BoxFit.cover, loadingBuilder: (context, child, chunk) => chunk == null ? child : Container( color: Colors.white12, child: const Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2)))), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), errorBuilder: (context, error, stack) => Container( color: Colors.white12, - child: const Icon(Icons.image_not_supported_outlined, - color: Colors.white38), - ), - ), - // 档位角标:s 高识别(绿)/ n 高性能(橙) - Positioned( - top: 4, - left: 4, - child: Container( - padding: - const EdgeInsets.symmetric(horizontal: 5, vertical: 1), - decoration: BoxDecoration( - color: accent, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - variant == kVariantS ? 's' : 'n', - style: const TextStyle( - color: Colors.black87, - fontSize: 10, - fontWeight: FontWeight.bold), + child: const Icon( + Icons.image_not_supported_outlined, + color: Colors.white38, ), ), ), @@ -214,110 +238,6 @@ class _ModelCard extends StatelessWidget { ), ); - Widget action; - if (progress != null) { - action = Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - LinearProgressIndicator( - value: progress, - backgroundColor: Colors.white12, - color: Colors.greenAccent), - const SizedBox(height: 2), - Row( - children: [ - Expanded( - child: Text( - '${(progress * 100).toStringAsFixed(0)}%', - textAlign: TextAlign.center, - style: - const TextStyle(color: Colors.white70, fontSize: 11), - ), - ), - TextButton( - onPressed: () => - manager.cancelDownload(item.datasetId, variant), - style: TextButton.styleFrom( - foregroundColor: Colors.white54, - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.symmetric(horizontal: 8), - minimumSize: const Size(0, 24), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: const Text('取消', style: TextStyle(fontSize: 11)), - ), - ], - ), - ], - ); - } else if (error != null) { - action = Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(error, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.redAccent, fontSize: 10)), - TextButton( - onPressed: () => manager.downloadModel(item), - child: const Text('重试', style: TextStyle(fontSize: 12)), - ), - ], - ); - } else if (active && downloaded) { - if (sameMode) { - // 当前档已激活:点击取消 - action = SizedBox( - height: 30, - child: OutlinedButton( - onPressed: () => - manager.setActive(item.datasetId, variant, false), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.greenAccent, - side: const BorderSide(color: Colors.greenAccent)), - child: const Text('已使用', style: TextStyle(fontSize: 12)), - ), - ); - } else { - // 非当前档已备好(激活保留):点击切换到该档立即生效 - action = SizedBox( - height: 30, - child: OutlinedButton( - onPressed: () => manager.setMode(variant), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.amberAccent, - side: const BorderSide(color: Colors.amberAccent)), - child: const Text('已备好', style: TextStyle(fontSize: 12)), - ), - ); - } - } else if (downloaded) { - action = SizedBox( - height: 30, - child: FilledButton( - onPressed: () => manager.setActive(item.datasetId, variant, true), - style: FilledButton.styleFrom( - backgroundColor: Colors.greenAccent, - foregroundColor: Colors.black, - visualDensity: VisualDensity.compact), - child: const Text('使用', style: TextStyle(fontSize: 12)), - ), - ); - } else { - action = SizedBox( - height: 30, - child: FilledButton( - onPressed: () => manager.downloadModel(item), - style: FilledButton.styleFrom( - backgroundColor: Colors.greenAccent, - foregroundColor: Colors.black, - visualDensity: VisualDensity.compact), - child: Text(sameMode ? '使用' : '下载', style: const TextStyle(fontSize: 12)), - ), - ); - } - return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -332,35 +252,226 @@ class _ModelCard extends StatelessWidget { Row( children: [ Expanded( - child: Text(item.datasetName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w600)), - ), - Container( - padding: - const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: accent.withValues(alpha: 0.25), - borderRadius: BorderRadius.circular(4), - ), child: Text( - variant == kVariantS ? '高识别' : '高性能', - style: TextStyle(color: accent, fontSize: 9), + target.datasetName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + ), ), ), ], ), const SizedBox(height: 2), - Text('v${item.version}', - style: const TextStyle(color: Colors.white38, fontSize: 10)), + // 双档行:高精度 / 高性能各自版本号;使用中绿色高亮、已下载次之、缺失置灰 + Text.rich( + TextSpan( + children: [ + for (final i in items) ...[ + if (i != items.first) const TextSpan(text: ' · '), + TextSpan( + text: '${_variantLabel(i.variant)} ${i.version}', + style: TextStyle( + fontSize: 9, + color: manager.isActive(i.datasetId, i.variant) + ? Colors.greenAccent + : manager.isDownloaded(i.datasetId, i.variant) + ? Colors.white70 + : Colors.white38, + fontWeight: manager.isActive(i.datasetId, i.variant) + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ], + ], + ), + ), const SizedBox(height: 6), - action, + _actionArea( + items: items, + target: target, + downloading: downloading, + hasError: hasError, + tActive: tActive, + tDownloaded: tDownloaded, + activeOther: activeOther, + ), ], ), ); } + + /// 动作区:进度/错误优先级最高;其次按目标档的下载/激活状态给主按钮 + Widget _actionArea({ + required List items, + required ModelCatalogItem target, + required bool downloading, + required bool hasError, + required bool tActive, + required bool tDownloaded, + required List activeOther, + }) { + if (downloading) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final i in items) + if (manager.progressOf(i.datasetId, i.variant) != null) + _progressRow(item: i), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () { + for (final i in items) { + if (manager.progressOf(i.datasetId, i.variant) != null) { + manager.cancelDownload(i.datasetId, i.variant); + } + } + }, + style: TextButton.styleFrom( + foregroundColor: Colors.white54, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8), + minimumSize: const Size(0, 24), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('取消', style: TextStyle(fontSize: 11)), + ), + ), + ], + ); + } + if (hasError) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final i in items) + if (manager.errorOf(i.datasetId, i.variant) != null) + Text( + '${_variantLabel(i.variant)}下载失败', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.redAccent, fontSize: 10), + ), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () { + for (final i in items) { + if (manager.errorOf(i.datasetId, i.variant) != null) { + manager.downloadModel(i); + } + } + }, + style: TextButton.styleFrom( + foregroundColor: Colors.redAccent, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8), + minimumSize: const Size(0, 24), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('重试', style: TextStyle(fontSize: 11)), + ), + ), + ], + ); + } + + final Widget mainBtn; + if (tActive) { + // 目标档使用中:点击取消使用 + mainBtn = SizedBox( + height: 30, + child: OutlinedButton( + onPressed: () => + manager.setActive(target.datasetId, target.variant, false), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.greenAccent, + side: const BorderSide(color: Colors.greenAccent), + ), + child: const Text('使用中', style: TextStyle(fontSize: 12)), + ), + ); + } else if (tDownloaded) { + // 已下载未激活(目标档):点「使用」直接启用——同物种另一档在使用会被 + // 自动停用(每数据集至多一档运行),统一文案不再叫「改用X」(2026-09-03) + mainBtn = SizedBox( + height: 30, + child: FilledButton( + onPressed: () => + manager.setActive(target.datasetId, target.variant, true), + style: FilledButton.styleFrom( + backgroundColor: Colors.greenAccent, + foregroundColor: Colors.black, + visualDensity: VisualDensity.compact, + ), + child: const Text('使用', style: TextStyle(fontSize: 12)), + ), + ); + } else { + // 目标档未下载:下载全部缺失档(目标档落地自动启用、伴档备好待切); + // 另一档在使用时按钮同样为「下载」:补下目标档后自动切换(停用另一档) + final use = activeOther.isNotEmpty; + mainBtn = SizedBox( + height: 30, + child: FilledButton( + onPressed: () async { + if (!use) { + for (final i in items) { + if (!manager.isDownloaded(i.datasetId, i.variant)) { + manager.downloadModel(i); + } + } + return; + } + if (!manager.isDownloaded(target.datasetId, target.variant)) { + final ok = await manager.downloadModel(target); + if (!ok) return; // 下载失败/取消:错误分支展示,保持现状 + } + await manager.setActive(target.datasetId, target.variant, true); + }, + style: FilledButton.styleFrom( + backgroundColor: Colors.greenAccent, + foregroundColor: Colors.black, + visualDensity: VisualDensity.compact, + ), + child: const Text('下载', style: TextStyle(fontSize: 12)), + ), + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [mainBtn], + ); + } + + Widget _progressRow({required ModelCatalogItem item}) { + final p = manager.progressOf(item.datasetId, item.variant) ?? 0.0; + final label = _variantLabel(item.variant); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Text( + '$label ${(p * 100).toStringAsFixed(0)}%', + style: const TextStyle(color: Colors.white70, fontSize: 10), + ), + ], + ), + LinearProgressIndicator( + value: p, + backgroundColor: Colors.white12, + color: Colors.greenAccent, + ), + ], + ); + } } diff --git a/flutter_app/lib/detection/detection_result.dart b/flutter_app/lib/detection/detection_result.dart index 2d76131..1687958 100644 --- a/flutter_app/lib/detection/detection_result.dart +++ b/flutter_app/lib/detection/detection_result.dart @@ -34,6 +34,9 @@ class DetectionResult { double get centerX => (left + right) / 2; double get centerY => (top + bottom) / 2; + /// 疑似(生境预警)类别:类别索引 >0 或训练标签为 suspect + bool get isSuspect => classId > 0 || label == 'suspect'; + DetectionResult copyWith({ double? score, double? left, diff --git a/flutter_app/lib/detection/detector_worker.dart b/flutter_app/lib/detection/detector_worker.dart index b6d7692..e4b82e9 100644 --- a/flutter_app/lib/detection/detector_worker.dart +++ b/flutter_app/lib/detection/detector_worker.dart @@ -229,15 +229,12 @@ Future _workerMain(SendPort mainPort) async { for (final entry in list[1] as List) { final e = entry as List; final name = e.length > 3 ? e[3] as String : ''; - // 模型名带档位标识(数据集名+档位,框来源可辨 s/n) - final variant = e.length > 4 ? e[4] as String : ''; - final displayName = - variant.isEmpty ? name : '$name($variant)'; + // modelName 仅标注来源数据集名;档位码 s/n 内部记账,不展示给用户 final d = await TfliteDetector.fromBuffer( e[0] as Uint8List, (e[1] as List).cast(), modelId: (e[2] as num).toInt(), - modelName: displayName, + modelName: name, ); if (d == null) { failures.add(name.isEmpty ? 'unknown' : name); @@ -475,8 +472,25 @@ Future _workerMain(SendPort mainPort) async { /// 实测多个模型会对同一目标检出不同类别(误检/歧义),若异类别互不压制 /// 会出现重叠框;2026-09-01 用户实测定案:所有模型的框统一按 IoU 去重, /// 重叠时取高分(远处真实的多目标互不重叠,正常保留)。 +/// +/// 2026-09-03 补跨模型同目标窗口:小框被大框覆盖 > [iouThreshold] 直接去重; +/// 覆盖不足、但两框来自**不同模型**且 [sameTarget](小框中心在大框内、 +/// 覆盖 ≥ 30%)也去重——不同输入分辨率模型对同一目标的框几何有系统性偏移, +/// 纯阈值会漏判。同模型框对(模型内已做过类内 NMS)不套用该窗口。 List mergeAcrossModels( List all, double iouThreshold) { if (all.length <= 1) return all; - return nms(all, iouThreshold); + final sorted = [...all]..sort((a, b) => b.score.compareTo(a.score)); + final kept = []; + for (final b in sorted) { + final dup = kept.any((k) { + if (boxOverlap(k, b) > iouThreshold) return true; + if (k.modelId == b.modelId || k.modelId < 0 || b.modelId < 0) { + return false; // 同模型/无来源:类内 NMS 已处理,不补窗 + } + return sameTarget(k, b); + }); + if (!dup) kept.add(b); + } + return kept; } diff --git a/flutter_app/lib/detection/nms.dart b/flutter_app/lib/detection/nms.dart index bda0e06..1fb394b 100644 --- a/flutter_app/lib/detection/nms.dart +++ b/flutter_app/lib/detection/nms.dart @@ -35,3 +35,22 @@ List nms(List boxes, double iouThreshold) { } return kept; } + +/// 同目标判定(2026-09-03 用户实测修订:同一标注位置/重叠位置,同/异模型 +/// 检出的物种只保留高置信度框)。不同模型对同一目标的框紧致度/偏移系统性 +/// 不同,纯 boxOverlap 阈值(0.45)会漏判「几何明显指向同一位置」的偏移框; +/// 补判条件:小框被大框覆盖 ≥ [sameTargetMinCover] 且小框中心落在大框内 +/// (相邻独立目标的中心不会落在对方框内,不会被误并)。 +const double sameTargetMinCover = 0.3; + +bool sameTarget(DetectionResult a, DetectionResult b) { + final cover = boxOverlap(a, b); + if (cover < sameTargetMinCover) return false; + final aBigger = a.width * a.height >= b.width * b.height; + final big = aBigger ? a : b; + final small = aBigger ? b : a; + return small.centerX >= big.left && + small.centerX <= big.right && + small.centerY >= big.top && + small.centerY <= big.bottom; +} diff --git a/flutter_app/lib/detection/tflite_detector.dart b/flutter_app/lib/detection/tflite_detector.dart index 93328a3..7a8bbc0 100644 --- a/flutter_app/lib/detection/tflite_detector.dart +++ b/flutter_app/lib/detection/tflite_detector.dart @@ -11,7 +11,7 @@ import 'nms.dart'; /// 模型输出布局(ultralytics litert 导出):[1, 4 + nc, anchors], /// cx/cy/w/h 已归一化,类别得分已过 sigmoid;按 out[dim][anchor] 索引。 /// 输入为 NCHW [1, 3, H, W](litert 导出保留 torch 布局),H/W 随模型档位: -/// s 高识别 @1280、n 高性能 @704,输入尺寸取自模型自身。 +/// s 高精度 @1280、n 高性能 @704,输入尺寸取自模型自身。 class TfliteDetector { // 输入尺寸取自模型本身(ultralytics litert 导出 NCHW [1,3,H,W],各数据集 // 训练 imgsz 可不同),默认 1280 兜底 diff --git a/flutter_app/lib/models/model_manager.dart b/flutter_app/lib/models/model_manager.dart index d13ea12..a8fd16c 100644 --- a/flutter_app/lib/models/model_manager.dart +++ b/flutter_app/lib/models/model_manager.dart @@ -9,7 +9,7 @@ import 'package:path_provider/path_provider.dart'; import '../config/app_config.dart'; -/// 识别档位标识:s = 高识别(@1280 精度优先,默认),n = 高性能(@704 速度优先) +/// 识别档位标识:s = 高精度(@1280 精度优先,默认),n = 高性能(@704 速度优先) const String kVariantS = 's'; const String kVariantN = 'n'; @@ -85,8 +85,17 @@ class ModelBundle { /// 档位标识符的「无子目录」形态,存量设备无需迁移),n 档存 `models//n/`; /// 各目录含 model.tflite + labels.json + meta.json,meta 记录 {version, sha256}, /// 版本与摘要都未变化时跳过下载。记账键一律是 (datasetId, variant) 二元组。 -/// 激活集与识别档位无关(跨档保持);推理加载「当前档位」下全部激活模型, -/// 切档即热加载新档位已激活模型([mode] 持久化本地)。 +/// 识别目标档位 [mode] 只是用户偏好(持久化):设置弹层卡片按钮面向该档位。 +/// 实际运行由激活集驱动——每个数据集**至多一个档位**在使用:激活某档会自动停用 +/// 同数据集另一档,不同数据集可用不同档位并行识别(2026-09-03 修订)。 +/// 激活集是**会话态**(2026-09-03 修订):每次进入视野页 [resetForSession] 清空、 +/// 不跨会话持久化——识别需用户在模型清单手动启用(显式「下载」落地即启用目标档 +/// 属于用户动作);上次崩溃/坏模型不会在下次打开时自动复现,用户总能看到仅预览 +/// 界面并自行调整。 +/// 目录**缓存优先**(2026-09-03):最近一次成功拉取的 models 目录落盘 +/// catalog.json,[refresh] 开头先载入缓存并通知(弹层离线也有内容展示),网络 +/// 成功后再以权威目录覆盖并落盘;清理/激活同步/自动更新只在网络成功(fetched) +/// 后执行——缓存降级时不清文件不下载,离线首启不误删已下载模型。 class ModelManager extends ChangeNotifier { static final ModelManager instance = ModelManager._(); @@ -103,7 +112,6 @@ class ModelManager extends ChangeNotifier { final Set _cancelRequested = {}; String _mode = kVariantS; bool _modeLoaded = false; - bool _activeLoaded = false; bool _ready = false; bool _refreshing = false; String? _error; @@ -117,7 +125,8 @@ class ModelManager extends ChangeNotifier { /// 服务器目录(弹层模型清单展示用;同一数据集可能 s/n 两行) List get catalog => _catalog; - /// 当前识别档位(s 高识别默认 / n 高性能),持久化,切档即热加载新档位模型 + /// 识别目标档位(默认 s 高精度):用户偏好,持久化;只决定卡片按钮与 + /// 首次下载的自动激活档,不直接切换已在运行的模型(运行看激活集) String get mode => _mode; bool isActive(int datasetId, String variant) => @@ -154,7 +163,7 @@ class ModelManager extends ChangeNotifier { _client = client ?? http.Client(), _rootDirOverride = rootDir; - /// 已激活且已下载、且属于当前档位的模型列表(空 = 未加载任何模型,仅预览) + /// 已激活且已下载的模型列表(每个数据集至多一个档位;空 = 未加载任何模型,仅预览) List get models => _models; /// 是否成功拉取过目录(即使下载失败也为 true,用于区分"从未联网"与"目录为空") @@ -165,20 +174,19 @@ class ModelManager extends ChangeNotifier { bool get refreshing => _refreshing; - /// 模型名摘要(诊断行展示):数据集名+档位 + /// 模型名摘要(诊断行展示):数据集名(同数据集的档位码不外显) String get modelsLabel { if (_models.isEmpty) return '未下载'; - return _models.map((m) => '${m.datasetName}(${m.variant})').join(','); + return _models.map((m) => m.datasetName).join(','); } - /// 切换识别档位:持久化本地并热加载该档位已激活模型(激活集跨档保留) + /// 切换识别目标档位:只改偏好并持久化(不切换已在运行的模型—— + /// 每个动物的实际档位由激活集决定,卡片按钮会面向新目标档给出「改用」操作) Future setMode(String variant) async { if (variant != kVariantS && variant != kVariantN) return; if (_mode == variant) return; _mode = variant; await _saveMode(); - _models = await _loadBundles(_catalog); - _revision++; notifyListeners(); } @@ -194,26 +202,55 @@ class ModelManager extends ChangeNotifier { return _inFlight!; } + /// 开始新识别会话(进入视野页时调用):清空激活集与已加载模型。 + /// 激活集为会话态、不做跨会话持久化——上次使用的模型不自动恢复,识别需 + /// 用户在模型清单手动启用(2026-09-03 会话制修订)。 + void resetForSession() { + if (_active.isEmpty) return; + _active.clear(); + _models = const []; + _revision++; + notifyListeners(); + } + Future _doRefresh() async { try { - await _loadActive(); await _loadMode(); - final res = await _client - .get(Uri.parse('$baseUrl/api/v1/app/update')) - .timeout(const Duration(seconds: 30)); - // 服务器 Content-Type 无 charset,http 包默认按 latin1 解码会乱码 → 显式 utf8 - final body = - jsonDecode(utf8.decode(res.bodyBytes)) as Map; - final data = body['data'] as Map? ?? const {}; - final list = data['models'] as List? ?? const []; - _catalog = list - .map((e) => ModelCatalogItem.fromJson(e as Map)) - .toList(); + // 缓存优先(2026-09-03):网络返回前先载入上次成功拉取的目录并提前 notify + // ——设置弹层打开即有内容展示,不依赖网络请求;网络成功后再以权威目录覆盖 + if (_catalog.isEmpty) { + await _loadCatalogCache(); + if (_catalog.isNotEmpty) notifyListeners(); + } + var fetched = false; + try { + final res = await _client + .get(Uri.parse('$baseUrl/api/v1/app/update')) + .timeout(const Duration(seconds: 30)); + // 服务器 Content-Type 无 charset,http 包默认按 latin1 解码会乱码 → 显式 utf8 + final body = + jsonDecode(utf8.decode(res.bodyBytes)) as Map; + final data = body['data'] as Map? ?? const {}; + final list = data['models'] as List? ?? const []; + _catalog = list + .map((e) => ModelCatalogItem.fromJson(e as Map)) + .toList(); + await _saveCatalogCache(list); + fetched = true; + } catch (e) { + // 拉取失败:保留缓存/旧目录继续展示;本实例从未拉取成功过才记错误 + // (有缓存兜底时同样提示,说明当前展示的目录未经最新网络确认) + if (!_ready) _error = '模型目录拉取失败:$e'; + } + // 无缓存且未拉取成功(目录确为空):无从同步,等下次刷新 + if (_catalog.isEmpty && !fetched) return; - // 只拉目录不下载;扫描本地已下载(meta+文件齐备)供清单展示 + // 只拉目录不下载;扫描本地已有模型文件供清单展示。版本新旧都算已下载: + // 旧版本文件由 autoUpdate 静默补齐,无需用户看到「下载」按钮再下。 + // 缓存目录同样扫描:离线重开也能正确标出已下载档位 final downloaded = {}; for (final item in _catalog) { - if (await _isLocal(item)) { + if (await _hasFile(item)) { downloaded.add((datasetId: item.datasetId, variant: item.variant)); } } @@ -221,18 +258,15 @@ class ModelManager extends ChangeNotifier { ..clear() ..addAll(downloaded); + // 清理/激活同步/自动更新只认网络拉到的权威目录:缓存降级时不清文件、 + // 不触发下载——离线首启不会误删已下载模型(2026-09-03) + if (!fetched) return; await _prune(_catalog); // 服务器已下线的 (数据集, 档位) 移出激活集 final catalogKeys = _catalog .map((c) => (datasetId: c.datasetId, variant: c.variant)) .toSet(); - final pruned = _active.where((k) => !catalogKeys.contains(k)).toList(); - if (pruned.isNotEmpty) { - for (final k in pruned) { - _active.remove(k); - } - await _saveActive(); - } + _active.removeWhere((k) => !catalogKeys.contains(k)); _models = await _loadBundles(_catalog); _ready = true; @@ -260,11 +294,14 @@ class ModelManager extends ChangeNotifier { } } - /// 按需下载并激活:流式下载 + sha256 校验 + 落盘(labels/meta); - /// 成功自动加入激活集(下载完成即使用;非当前档位则备好待切档)。 - /// 失败重试一次并记录错误。 + /// 按需下载:流式下载 + sha256 校验 + 落盘(labels/meta)。 + /// [autoActivate](默认 true,用户显式下载)该数据集此前无任何档位在使用且 + /// 本档为目录中唯一可选/匹配目标档时自动激活(下载即有识别); + /// autoUpdate 等后台补档传 false:只更新文件不改变激活状态。 + /// 使用中的档位原地更新则字节生效(重建推理 worker)。失败重试一次并记录错误。 Future downloadModel(ModelCatalogItem item, - {void Function(int received, int total)? onProgress}) async { + {bool autoActivate = true, + void Function(int received, int total)? onProgress}) async { final key = (datasetId: item.datasetId, variant: item.variant); // 并发保护:同一 (数据集, 档位) 已有进行中的下载则直接短路 if (_progress.containsKey(key)) return false; @@ -284,14 +321,25 @@ class ModelManager extends ChangeNotifier { if (ok) { _progress.remove(key); _errors.remove(key); + final wasActive = _active.contains(key); _downloaded.add(key); - if (item.variant == _mode) { - // 字节替换生效(当前档位模型更新需重建 worker 读新文件) + if (wasActive) { + // 使用中的模型原地更新:字节已替换,重建 worker 读新文件 _revision++; + _models = await _loadBundles(_catalog); + } else if (autoActivate && + !_active.any((k) => k.datasetId == item.datasetId)) { + // 用户显式下载且该数据集尚无档位在使用:自动激活目标档条目; + // 目录没有目标档(存量单档物种)时激活本条,保证下载即有识别 + final hasTarget = _catalog.any((c) => + c.datasetId == item.datasetId && c.variant == _mode); + if (item.variant == _mode || !hasTarget) { + _active.add(key); + _revision++; + _models = await _loadBundles(_catalog); + } } - _models = await _loadBundles(_catalog); notifyListeners(); - await setActive(item.datasetId, item.variant, true); return true; } if (_cancelRequested.contains(key)) break; @@ -423,84 +471,49 @@ class ModelManager extends ChangeNotifier { } } - /// 设置激活状态(true=使用,false=取消);持久化到 `root/active.json`。 - /// 激活跨档位保留(切档后仍生效);仅影响当前档位加载时才需重建推理 worker。 - /// 未下载的模型不可激活(下载完成由 downloadModel 自动激活)。 + /// 设置激活状态(true=使用,false=取消;仅本次会话内生效,不持久化)。 + /// 同一数据集至多一个档位在使用:激活某档时若同数据集另一档在使用则先停用 + /// (2026-09-03:不同动物可跑不同档位,同一种动物一次只跑一档)。 + /// 变化即重建推理 worker。未下载的模型不可激活(下载完成按目标档自动激活)。 Future setActive(int datasetId, String variant, bool active) async { final key = (datasetId: datasetId, variant: variant); - final changed = active ? _active.add(key) : _active.remove(key); - if (!changed) return; - if (variant == _mode) { - _revision++; - _models = await _loadBundles(_catalog); + if (!active) { + if (!_active.remove(key)) return; + } else { + final keyActive = _active.contains(key); + final sameDsOthers = _active + .where((k) => k.datasetId == datasetId && k.variant != variant) + .toList(); + if (keyActive && sameDsOthers.isEmpty) return; // 状态未变化 + _active.removeAll(sameDsOthers); + _active.add(key); } - await _saveActive(); + _revision++; + _models = await _loadBundles(_catalog); notifyListeners(); } - /// 自动更新:已下载/已激活的模型,目录出现新版本时自动重下(保持原激活状态; - /// 未下载的模型不自动拉取,避免无谓流量)。下载进度经 downloadModel 通知。 + /// 自动更新:已下载的模型目录出现新版本时自动重下(不改变激活状态—— + /// 激活只在用户显式下载/启用时发生,2026-09-03 会话制修订)。 + /// 使用中的模型原地更新则立即生效(重建推理 worker)。未下载的模型不自动拉取, + /// 避免无谓流量。下载进度经 downloadModel 通知。 /// 2026-09-01 用户需求:发布新模型后 App 端自动更新,无需手动触发。 Future autoUpdate() async { if (_catalog.isEmpty) return; - final tracked = {..._downloaded, ..._active}; for (final item in _catalog) { final key = (datasetId: item.datasetId, variant: item.variant); - if (!tracked.contains(key)) continue; - final wasActive = _active.contains(key); + if (!_downloaded.contains(key)) continue; try { // 后台 fire-and-forget:本地检查/下载都可能撞上存储变动(如清理), // 不得向外抛未处理异步异常 if (await _isLocal(item)) continue; - final ok = await downloadModel(item); - // 原本未激活:下载完成自动激活后恢复原状态 - if (ok && !wasActive) { - await setActive(item.datasetId, item.variant, false); - } + await downloadModel(item, autoActivate: false); } catch (e) { debugPrint('[ModelManager] 自动更新失败: ${item.datasetName} $e'); } } } - Future _saveActive() async { - try { - final root = await _rootDir(); - await root.create(recursive: true); - await File('${root.path}/active.json').writeAsString(jsonEncode({ - 'active': [ - for (final k in _active) {'d': k.datasetId, 'v': k.variant} - ] - })); - } catch (e) { - debugPrint('[ModelManager] 激活集持久化失败: $e'); - } - } - - /// 读取激活集。兼容旧版纯 int 列表(双档位前只有 s 档,int 一律归为 s)。 - Future _loadActive() async { - if (_activeLoaded) return; - _activeLoaded = true; - try { - final root = await _rootDir(); - final f = File('${root.path}/active.json'); - if (!await f.exists()) return; - final data = jsonDecode(await f.readAsString()) as Map; - for (final e in data['active'] as List? ?? const []) { - if (e is num) { - _active.add((datasetId: e.toInt(), variant: kVariantS)); - } else if (e is Map) { - final v = e['v'] as String? ?? kVariantS; - final d = (e['d'] as num?)?.toInt(); - if (d != null && v != kVariantS && v != kVariantN) continue; - if (d != null) _active.add((datasetId: d, variant: v)); - } - } - } catch (e) { - debugPrint('[ModelManager] 激活集读取失败: $e'); - } - } - Future _saveMode() async { try { final root = await _rootDir(); @@ -527,11 +540,42 @@ class ModelManager extends ChangeNotifier { } } + /// 载入上次成功拉取的目录缓存(catalog.json,模型根目录下)——离线/弱网时 + /// 设置弹层也能先展示模型清单。无缓存文件/损坏/空列表则保持目录为空。 + Future _loadCatalogCache() async { + try { + final root = await _rootDir(); + final f = File('${root.path}/catalog.json'); + if (!await f.exists()) return; + final data = jsonDecode(await f.readAsString()) as Map; + final list = data['models'] as List? ?? const []; + if (list.isEmpty) return; + _catalog = list + .map((e) => ModelCatalogItem.fromJson(e as Map)) + .toList(); + } catch (e) { + debugPrint('[ModelManager] 目录缓存读取失败: $e'); + } + } + + /// 落盘最近一次成功拉取的 models 原始列表(含服务器可能新增的字段), + /// 供下次离线/网络慢时先展示;缓存仅作展示降级,不参与清理/自动更新决策。 + Future _saveCatalogCache(List rawModels) async { + try { + final root = await _rootDir(); + await root.create(recursive: true); + await File('${root.path}/catalog.json') + .writeAsString(jsonEncode({'models': rawModels})); + } catch (e) { + debugPrint('[ModelManager] 目录缓存保存失败: $e'); + } + } + + /// 读取全部激活条目(不再按目标档过滤——激活集即实际运行集,每数据集一档) Future> _loadBundles( List catalog) async { final bundles = []; for (final item in catalog) { - if (item.variant != _mode) continue; final key = (datasetId: item.datasetId, variant: item.variant); if (!_active.contains(key)) continue; try { @@ -576,13 +620,24 @@ class ModelManager extends ChangeNotifier { return Directory('${support.path}/models'); } - /// 档位目录:s 档存 `models//`(legacy 无子目录,目录键 = 档位 - /// 标识符的 s 形态,存量设备零迁移);n 档存 `models//n/`。 + /// 档位子路径(相对模型根目录):s 档 `models//`(legacy 无子目录, + /// 目录键 = 档位标识符的 s 形态,存量设备零迁移);n 档 `models//n/`。 + String _subPath(int datasetId, String variant) => + variant == kVariantS ? '$datasetId' : '$datasetId/$variant'; + + /// 档位目录(不存在则创建) Future _modelDir(int datasetId, String variant) async { final root = await _rootDir(); - final sub = variant == kVariantS ? '' : '/$variant'; - final dir = Directory('${root.path}/$datasetId$sub'); + final dir = Directory('${root.path}/${_subPath(datasetId, variant)}'); await dir.create(recursive: true); return dir; } + + /// 目录条目对应的模型文件是否已存在本地(不校验版本:旧版本视为已下载, + /// 新版本由 autoUpdate 自动补齐) + Future _hasFile(ModelCatalogItem item) async { + final root = await _rootDir(); + return File('${root.path}/${_subPath(item.datasetId, item.variant)}/model.tflite') + .exists(); + } } diff --git a/flutter_app/lib/reminder/reminder.dart b/flutter_app/lib/reminder/reminder.dart index 28c6826..99bb45d 100644 --- a/flutter_app/lib/reminder/reminder.dart +++ b/flutter_app/lib/reminder/reminder.dart @@ -6,7 +6,9 @@ import 'package:vibration/vibration.dart'; /// 提醒:同类目标 10s 内只提醒一次。 /// 震动/提示音默认开启,不提供关闭入口。 class Reminder { - final AudioPlayer _player = AudioPlayer(); + // 惰性创建:AudioPlayer 构造即发起平台初始化(无插件环境下未处理错误会 + // 泄漏为 unhandled async error),首响时才建 + AudioPlayer? _player; String? _lastAlertLabel; int _lastAlertAt = 0; @@ -24,11 +26,12 @@ class Reminder { void _vibrate() => Vibration.vibrate(duration: 200); Future _playTone() async { - await _player.stop(); - await _player.play(AssetSource('beep.wav')); + final p = _player ??= AudioPlayer(); + await p.stop(); + await p.play(AssetSource('beep.wav')); } void release() { - _player.dispose(); + _player?.dispose(); } } diff --git a/flutter_app/lib/update/installer.dart b/flutter_app/lib/update/installer.dart deleted file mode 100644 index 1237240..0000000 --- a/flutter_app/lib/update/installer.dart +++ /dev/null @@ -1,49 +0,0 @@ -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 install(String path) => - _method.invokeMethod('install', {'path': path}).then( - (v) => v ?? 'installing', - ); - - /// 安装进度流:progress(0-100) → finished(success) / failed(error) - static Stream 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?, - ); - }); - } -} diff --git a/flutter_app/lib/update/update_checker.dart b/flutter_app/lib/update/update_checker.dart index 05ab23f..48ed62d 100644 --- a/flutter_app/lib/update/update_checker.dart +++ b/flutter_app/lib/update/update_checker.dart @@ -17,7 +17,7 @@ class AppUpdateInfo { } /// 启动时版本更新检查:仅 Android 检查;服务器版本高于本地版本即强制更新 -/// (无普通/强制之分)。 +/// (无普通/强制之分)。2026-09-03 起更新走浏览器下载 APK 手动安装。 class UpdateChecker { final String baseUrl; final http.Client _client; @@ -52,12 +52,11 @@ class UpdateChecker { } } - /// 是否需要更新:服务器版本高于「已装版本与已确认接受版本」中的较大者。 - /// APK 版本号不递增时,用户点过「立即更新」后 accepted 追上服务器版本, - /// 已更新完成再次启动也不会反复提示。 - static bool needsUpdate(String server, String installed, String accepted) { + /// 是否需要更新:服务器版本高于已装版本(2026-09-03 起更新改浏览器下载, + /// 安装结果由系统安装器完成,App 无法感知,故不再记录「已确认接受版本」) + static bool needsUpdate(String server, String installed) { if (server.isEmpty || installed.isEmpty) return false; - return isNewer(server, installed) || isNewer(server, accepted); + return isNewer(server, installed); } /// 语义化版本号比较:a > b 返回 true。按数字段比较(1.10.0 > 1.9.9), diff --git a/flutter_app/lib/update/update_screen.dart b/flutter_app/lib/update/update_screen.dart index 81c5cec..9bc800d 100644 --- a/flutter_app/lib/update/update_screen.dart +++ b/flutter_app/lib/update/update_screen.dart @@ -1,219 +1,53 @@ -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 版本号不递增时也不反复提示)。 +/// 2026-09-03 改为浏览器下载:App 内 PackageInstaller 会话安装在小米等 +/// ROM 上点系统确认框后失败(下载/写入正常但最终安装被拒),而系统浏览器 +/// 下载 APK 后经通知栏走 ROM 自己的安装器可正常完成,故下载链接改由浏览器 +/// 打开,不再 App 内下载/安装。仅点「立即更新」按钮时打开浏览器,不自动 +/// 跳转;安装完成(versionName 追上服务器)后下次启动不再提示。 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 createState() => _UpdateScreenState(); } -enum _Stage { idle, downloading, installing, finished, failed } - class _UpdateScreenState extends State { - 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? _installSub; - /// 安装超时兜底:确认框未处理/系统无回调时避免永久卡「安装中」 - Timer? _installTimer; - - @override - void initState() { - super.initState(); - // 自动更新:进页即自动下载并安装,无需手动点击(2026-09-01 用户需求) - WidgetsBinding.instance.addPostFrameCallback((_) => _launch()); - } - - @override - void dispose() { - _installTimer?.cancel(); - _installSub?.cancel(); - _client.close(); - super.dispose(); - } - - Future _launch() async { - if (_stage == _Stage.downloading || - _stage == _Stage.installing || - _stage == _Stage.finished) { + Future _openBrowser() async { + final uri = Uri.tryParse(widget.url); + if (uri == null) { + setState(() => _message = '下载链接无效,请联系管理员'); return; } - setState(() { - _stage = _Stage.idle; - _message = null; - }); - 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 _download() async { - setState(() { - _stage = _Stage.downloading; - _progress = 0; - }); try { - if (_apkPart.existsSync()) _apkPart.deleteSync(); - // 下载无总时长上限(APK 几十 MB 慢网可能数分钟);连接/响应头与 - // 数据流分别做 30s 停滞判定,避免断流黑洞永久卡死 - final res = await _client - .send(http.Request('GET', Uri.parse(widget.url))) - .timeout(const Duration(seconds: 30)); - 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.timeout(const Duration(seconds: 30))) { - sink.add(chunk); - received += chunk.length; - if (mounted && total > 0) { - setState(() => _progress = received / total * 100); - } - } - await sink.close(); - // 原子落盘:下载完成后才重命名为正式文件,避免残留半包被当成完整 APK - _apkPart.renameSync(_apkFile.path); + final ok = await launchUrl(uri, mode: LaunchMode.externalApplication); if (!mounted) return; - await _install(); + setState(() { + _message = ok ? '已打开浏览器下载,完成后点击通知栏的安装提示即可更新' : '打开浏览器失败,请点「立即更新」重试'; + }); } catch (_) { - if (_apkPart.existsSync()) _apkPart.deleteSync(); if (!mounted) return; - setState(() { - _stage = _Stage.failed; - _progress = null; - _message = '下载失败,请检查网络后重试'; - }); - } - } - - Future _install() async { - setState(() { - _stage = _Stage.installing; - _progress = 0; - _message = null; - }); - await _installSub?.cancel(); - // 安装超时兜底:确认框未处理/系统无回调时避免永久卡「安装中」 - _installTimer?.cancel(); - _installTimer = Timer(const Duration(seconds: 120), () { - if (!mounted) return; - setState(() { - _stage = _Stage.failed; - _message = '安装超时,请重试'; - }); - }); - _installSub = ApkInstaller.progress().listen((e) { - if (!mounted) return; - switch (e.event) { - case 'progress': - setState(() => _progress = e.progress.toDouble()); - break; - case 'finished': - _installTimer?.cancel(); - final ok = e.success == true; - // 安装成功才记录已接受版本:失败/取消时下次启动仍提示重试 - if (ok) widget.onUpdateAccepted?.call(); - setState(() { - _stage = ok ? _Stage.finished : _Stage.failed; - _message = ok ? '安装完成,请从桌面打开新版应用' : '安装失败,请重试'; - }); - break; - case 'failed': - _installTimer?.cancel(); - debugPrint('UpdateScreen: install failed: ${e.error}'); - setState(() { - _stage = _Stage.failed; - _message = e.error ?? '安装失败,请重试'; - }); - break; - } - }, onError: (Object _) { - _installTimer?.cancel(); - if (!mounted) return; - setState(() { - _stage = _Stage.failed; - _message = '安装失败,请重试'; - }); - }); - final String result; - try { - result = await ApkInstaller.install(_apkFile.path); - } catch (_) { - // 原生安装通道异常(如会话创建失败):不捕获则 UI 永久停在「安装中」 - _installTimer?.cancel(); - if (!mounted) return; - setState(() { - _stage = _Stage.failed; - _message = '安装启动失败,请重试'; - }); - return; - } - if (!mounted) return; - if (result == 'permission_required') { - // 原生侧已拉起系统设置页;APK 已缓存,用户开启后返回再点直达安装 - _installTimer?.cancel(); - setState(() { - _stage = _Stage.failed; - _message = '请在系统设置中允许「安装未知应用」,返回后再次点击「立即更新」(APK 已缓存,无需重新下载)'; - }); + setState(() => _message = '打开浏览器失败,请点「立即更新」重试'); } } @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( @@ -234,26 +68,22 @@ class _UpdateScreenState extends State { 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 ? '已完成' : '立即更新'), + onPressed: _openBrowser, + icon: const Icon(Icons.open_in_browser), + label: const Text('立即更新'), style: FilledButton.styleFrom( minimumSize: const Size(200, 48), textStyle: const TextStyle(fontSize: 16), ), ), + const SizedBox(height: 16), + Text( + '新版本需在浏览器下载 APK 后手动安装\n安装完成后请重新打开应用', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.error), + ), if (_message != null) ...[ const SizedBox(height: 12), Text( @@ -261,16 +91,12 @@ class _UpdateScreenState extends State { textAlign: TextAlign.center, style: TextStyle( height: 1.5, - color: _stage == _Stage.failed - ? theme.colorScheme.error - : Colors.green, + color: _message!.startsWith('已打开') + ? Colors.green + : theme.colorScheme.error, ), ), ], - const SizedBox(height: 12), - Text('不更新将无法继续使用', - style: theme.textTheme.bodySmall - ?.copyWith(color: theme.colorScheme.error)), ], ), ), diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml index a88e057..9e35712 100644 --- a/flutter_app/pubspec.yaml +++ b/flutter_app/pubspec.yaml @@ -2,7 +2,7 @@ name: observer description: "视野 - 动物实时识别 (环颈雉鸡/生境), YOLOv8 + 充值付费" publish_to: 'none' -version: 1.0.24+29 +version: 1.0.35+40 environment: sdk: ^3.12.2 diff --git a/flutter_app/test/camera_view_model_test.dart b/flutter_app/test/camera_view_model_test.dart new file mode 100644 index 0000000..77c27e2 --- /dev/null +++ b/flutter_app/test/camera_view_model_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:observer/camera/camera_view_model.dart'; +import 'package:observer/detection/detection_result.dart'; +import 'package:observer/reminder/reminder.dart'; + +DetectionResult box( + String label, + double score, + double l, + double t, + double r, + double b, { + int modelId = -1, + int classId = 0, +}) => + DetectionResult( + label: label, + score: score, + left: l, + top: t, + right: r, + bottom: b, + modelId: modelId, + classId: classId, + ); + +/// 真实 Reminder 会触碰音频/震动插件通道,测试用记录桩(onDetected 同步记录)。 +/// AudioPlayer 构造里的 _create 平台调用在测试环境抛 MissingPluginException, +/// 已被插件内部 catch,不影响用例。 +class _RecordingReminder extends Reminder { + final List alerts = []; + @override + void onDetected(String label) => alerts.add(label); +} + +void feed(CameraViewModel vm, List results) { + vm.onFramesAnalyzed(results, 90, 1280, 720, const [], const [], + lastProcessMs: 8); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('dedupeVisibleOverlaps', () { + test('同类别同位置重复框:只保留最高分', () { + final visible = [ + box('雉鸡', 0.6, 0.3, 0.3, 0.5, 0.5, modelId: 1), + box('斑鸠', 0.5, 0.3, 0.3, 0.5, 0.5, modelId: 2), + ]; + final out = dedupeVisibleOverlaps(visible); + expect(out.length, 1); + expect(out.single.label, '雉鸡'); + expect(out.single.score, 0.6); + }); + + test('不同类别同位置(目标×疑似):不同语义各自保留', () { + final visible = [ + box('雉鸡', 0.6, 0.3, 0.3, 0.5, 0.5, classId: 0), + box('生境', 0.8, 0.2, 0.2, 0.6, 0.6, classId: 1), + ]; + final out = dedupeVisibleOverlaps(visible); + expect(out.length, 2); + }); + + test('中心不在对方框内的偏移重叠:两个独立目标不误并', () { + // 覆盖 ~40% 但中心互不在对方框内(并肩两个目标) + final visible = [ + box('雉鸡', 0.7, 0.10, 0.10, 0.40, 0.40), + box('雉鸡', 0.5, 0.28, 0.10, 0.58, 0.40), + ]; + expect(dedupeVisibleOverlaps(visible).length, 2); + }); + }); + + test('跨标签同目标补挂:交替检出同一目标只一条轨迹/一次提醒', () async { + final reminder = _RecordingReminder(); + final vm = CameraViewModel(reminder: reminder); + feed(vm, [box('雉鸡', 0.6, 0.2, 0.2, 0.5, 0.5, modelId: 1)]); + // 越过 500ms 显示窗(确认轨迹开始显示、提醒窗口开启) + await Future.delayed(const Duration(milliseconds: 600)); + // 模型 2 检出同一目标:细长小框贴大框边角(中心距 ~0.2 > 跨标签收紧半径 + // 0.072、覆盖 ~0.31 也不到 0.45),但小框中心在大框内 → 补挂进雉鸡 + // 轨迹而不是新建第二条 + feed(vm, [box('斑鸠', 0.55, 0.38, 0.445, 0.62, 0.535, modelId: 2)]); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(reminder.alerts, ['雉鸡'], + reason: '补挂进原轨迹:提醒按轨迹原标签只触发一次;若新建第二条轨迹' + '(斑鸠未达 500ms 显示窗)本帧不会触发提醒'); + expect(vm.state.results.length, 1, reason: '同位置只有一条轨迹可见'); + expect(vm.state.results.single.label, '斑鸠', reason: '框内容跟随后到的检测'); + }); +} diff --git a/flutter_app/test/model_catalog_section_test.dart b/flutter_app/test/model_catalog_section_test.dart index 5766837..3493c2f 100644 --- a/flutter_app/test/model_catalog_section_test.dart +++ b/flutter_app/test/model_catalog_section_test.dart @@ -120,33 +120,33 @@ void main() { await tester.tap(finder); } - testWidgets('未下载:显示使用按钮,点击后下载完成自动变为已使用', (tester) async { + testWidgets('未下载:显示下载按钮,点击后下载完成自动变为使用中', (tester) async { final m = manager([_item()]); await tester.runAsync(() => m.refresh()); await pumpSection(tester, m); expect(find.text('数据集A'), findsOneWidget); - expect(find.text('使用'), findsOneWidget); + expect(find.text('下载'), findsOneWidget); - await tapVisible(tester, find.text('使用')); + await tapVisible(tester, find.text('下载')); await tester.pump(); // 下载启动,进度条出现 - // 驱动下载 + 自动激活的 IO 链走完,直至 UI 呈现「已使用」 - await pumpUntilFound(tester, find.text('已使用')); + // 驱动下载 + 自动激活的 IO 链走完,直至 UI 呈现「使用中」 + await pumpUntilFound(tester, find.text('使用中')); await tester.pumpAndSettle(); expect(m.isDownloaded(7, kVariantS), isTrue); expect(m.isActive(7, kVariantS), isTrue); - expect(find.text('已使用'), findsOneWidget); + expect(find.text('使用中'), findsOneWidget); }); - testWidgets('已激活:再次点击取消使用', (tester) async { + testWidgets('已激活:点击使用中取消使用', (tester) async { final m = manager([_item()]); await tester.runAsync(() => m.refresh()); await tester.runAsync(() => m.downloadModel(m.catalog.first)); await pumpSection(tester, m); - expect(find.text('已使用'), findsOneWidget); - await tapVisible(tester, find.text('已使用')); + expect(find.text('使用中'), findsOneWidget); + await tapVisible(tester, find.text('使用中')); // 排空 setActive 的激活集重载与落盘 IO,直至 UI 呈现「使用」 await pumpUntilFound(tester, find.text('使用')); await tester.pumpAndSettle(); @@ -169,7 +169,7 @@ void main() { expect(find.text('暂无已发布模型'), findsOneWidget); }); - testWidgets('下载中:显示进度和取消按钮,取消后恢复使用', (tester) async { + testWidgets('下载中:显示进度和取消按钮,取消后恢复下载', (tester) async { final m = ModelManager( baseUrl: 'http://test.local', client: _StreamingClient(), @@ -178,83 +178,156 @@ void main() { await tester.runAsync(() => m.refresh()); await pumpSection(tester, m); - await tapVisible(tester, find.text('使用')); + await tapVisible(tester, find.text('下载')); // 首个分块到达,进度条与取消按钮出现(IO 链需交替驱动) await pumpUntilFound(tester, find.text('取消'), fake: const Duration(milliseconds: 400)); expect(find.text('取消'), findsOneWidget); await tapVisible(tester, find.text('取消')); - // 推进 fake 时钟触发延迟分块 → 取消分支清场(.part 删除等真实 IO),直至恢复「使用」 - await pumpUntilFound(tester, find.text('使用'), + // 推进 fake 时钟触发延迟分块 → 取消分支清场(.part 删除等真实 IO),直至恢复「下载」 + await pumpUntilFound(tester, find.text('下载'), fake: const Duration(milliseconds: 400)); await tester.pumpAndSettle(); expect(m.isDownloaded(7, kVariantS), isFalse); expect(m.isActive(7, kVariantS), isFalse); - expect(find.text('使用'), findsOneWidget); + expect(find.text('下载'), findsOneWidget); }); - testWidgets('双档位:s/n 两卡独立,n 档下载备好后切档即用', (tester) async { - final m = - manager([_item(), _item(variant: kVariantN, name: '数据集B')]); + testWidgets('双档位:同物种合并一张卡,下载只启用目标档,使用即停旧档', (tester) async { + final m = manager([_item(), _item(variant: kVariantN)]); await tester.runAsync(() => m.refresh()); await pumpSection(tester, m); - // 分段控件默认 s;两卡并排,s 卡按钮「使用」,非当前档 n 卡按钮「下载」 - expect(find.text('s 高识别'), findsOneWidget); + // 合并卡:同一数据集只一张卡,无双卡/无档位角标;双档行标注两档 expect(find.text('数据集A'), findsOneWidget); - expect(find.text('数据集B'), findsOneWidget); - expect(find.text('s'), findsOneWidget, reason: '档位角标'); - expect(find.text('n'), findsOneWidget, reason: '档位角标'); - expect(find.text('使用'), findsOneWidget); - expect(find.text('下载'), findsOneWidget, reason: '非当前档入口为「下载」'); + expect(find.text('下载'), findsOneWidget, reason: '合并卡只有一个主按钮'); + expect(find.textContaining('高精度 v1.0.0'), findsOneWidget); + expect(find.textContaining('高性能 v1.0.0'), findsOneWidget); - // 先激活 s 档 - await tapVisible(tester, find.text('使用')); - await pumpUntilFound(tester, find.text('已使用')); - expect(m.isActive(7, kVariantS), isTrue); - - // 下载 n 档:完成后自动备好(n 非当前档),显示「已备好」而非「已使用」 + // 一次下载取回双档:同物种只启用目标档(默认高精度 s),n 档备好不启用。 + // 双档下载收尾链交错,等 models 真正重载完成再断言 await tapVisible(tester, find.text('下载')); - await pumpUntilFound(tester, find.text('已备好')); - await tester.pumpAndSettle(); - expect(m.isActive(7, kVariantN), isTrue); - expect(m.mode, kVariantS, reason: '下载非当前档不应改变识别档位'); - expect(m.models.length, 1); - expect(m.models.first.variant, kVariantS, reason: '当前仍只加载 s 档'); - - // 点「已备好」切换档位 → n 档立即生效(已使用),s 档转为备好态 - await tapVisible(tester, find.text('已备好')); - // 「已使用」切换前后都在场(s 使用 → n 使用),须等 manager 状态真正切档 + await pumpUntilFound(tester, find.text('使用中')); for (var i = 0; i < 50 && - !(m.mode == kVariantN && - m.models.isNotEmpty && - m.models.first.variant == kVariantN); + !(m.isDownloaded(7, kVariantS) && + m.isDownloaded(7, kVariantN) && + m.isActive(7, kVariantS) && + !m.isActive(7, kVariantN) && + m.models.length == 1 && + m.models.first.variant == kVariantS); i++) { await pumpRealIo(tester); } await tester.pumpAndSettle(); - expect(m.mode, kVariantN); + + expect(m.isDownloaded(7, kVariantS), isTrue); + expect(m.isDownloaded(7, kVariantN), isTrue); + expect(m.isActive(7, kVariantS), isTrue); + expect(m.isActive(7, kVariantN), isFalse, + reason: '同物种同时只运行目标档,另一档备好不叠加启用'); + expect(m.mode, kVariantS, reason: '下载双档不应改变目标档'); expect(m.models.length, 1); - expect(m.models.first.variant, kVariantN, reason: '切档后热加载 n 档模型'); - expect(find.text('已使用'), findsOneWidget, reason: 'n 档卡已使用'); - expect(find.text('已备好'), findsOneWidget, reason: 's 档转为备好态'); + expect(m.models.first.variant, kVariantS); + expect(find.text('使用中'), findsOneWidget); + + // 目标档切到高性能:运行中的 s 不受影响;n 已下载未启用 → 主按钮「使用」 + // (保存目标档偏好是真实 IO,等按钮呈现才算 UI 落定;2026-09-03 简化: + // 不再叫「改用X」、不显示「当前使用 X」提示) + await tapVisible(tester, find.text('高性能')); + await pumpUntilFound(tester, find.text('使用')); + await tester.pumpAndSettle(); + expect(m.mode, kVariantN); + expect(m.isActive(7, kVariantS), isTrue, + reason: '切换目标档不应改变运行中的模型'); + expect(find.text('使用'), findsOneWidget); + expect(find.textContaining('当前使用'), findsNothing); + + // 点「使用」:同物种 s 自动停用,n 热启用(setActive 尾部的落盘 + // 与 notify 是真实 IO,等 UI 呈现「使用中」才算整链落定) + await tapVisible(tester, find.text('使用')); + await pumpUntilFound(tester, find.text('使用中')); + await tester.pumpAndSettle(); + expect(m.isActive(7, kVariantS), isFalse, reason: '启用目标档自动停用另一档'); + expect(m.isActive(7, kVariantN), isTrue); + expect(m.models.length, 1); + expect(m.models.first.variant, kVariantN); + expect(find.text('使用中'), findsOneWidget, reason: '主按钮跟随当前档'); }); - testWidgets('识别模式分段控件:点击切档生效', (tester) async { - final m = manager([_item(), _item(variant: kVariantN, name: '数据集A')]); + testWidgets('不同动物可用不同档位:使用只作用于点击的动物', (tester) async { + final m = manager([ + _item(datasetId: 7, name: '数据集A'), + _item(datasetId: 7, variant: kVariantN, name: '数据集A'), + _item(datasetId: 8, name: '数据集B'), + _item(datasetId: 8, variant: kVariantN, name: '数据集B'), + ]); await tester.runAsync(() => m.refresh()); + // 两个物种都下载并运行默认档(高精度 s),各自 n 档备好 + for (final it in m.catalog) { + await tester.runAsync(() => m.downloadModel(it)); + } await pumpSection(tester, m); - await tapVisible(tester, find.text('n 高性能')); - for (var i = 0; i < 50 && m.mode != kVariantN; i++) { + expect(m.models.length, 2); + expect(find.text('使用中'), findsNWidgets(2)); + + // 目标档切高性能:两卡主按钮都变「使用」(各自 s 仍在运行、n 已备好) + await tapVisible(tester, find.text('高性能')); + await pumpUntilFound(tester, find.text('使用')); + expect(find.text('使用'), findsNWidgets(2)); + + // 只点第一张卡(数据集 7,目录序在前):该动物切到高性能,另一动物保持高精度 + await tapVisible(tester, find.text('使用').first); + await pumpUntilFound(tester, find.text('使用中')); // 数据集 7 的卡先翻为使用中 + for (var i = 0; + i < 50 && + !(m.models.length == 2 && + m.models.firstWhere((b) => b.datasetId == 7).variant == + kVariantN && + m.models.firstWhere((b) => b.datasetId == 8).variant == + kVariantS); + i++) { await pumpRealIo(tester); } await tester.pumpAndSettle(); - expect(m.mode, kVariantN, reason: '点击分段控件应切档'); - // 档位持久化(重启恢复)由 model_manager_test「识别档位持久化」覆盖; + + expect(m.isActive(7, kVariantN), isTrue); + expect(m.isActive(7, kVariantS), isFalse, reason: '启用目标档自动停用同动物另一档'); + expect(m.isActive(8, kVariantS), isTrue, reason: '未点击的动物保持原档位'); + expect(m.isActive(8, kVariantN), isFalse); + expect(m.models.length, 2, reason: '两个动物仍同时识别'); + expect(m.models.firstWhere((b) => b.datasetId == 7).variant, kVariantN); + expect(m.models.firstWhere((b) => b.datasetId == 8).variant, kVariantS); + expect(find.text('使用中'), findsOneWidget, reason: '只有数据集 7 的卡使用中'); + expect(find.text('使用'), findsOneWidget, + reason: '数据集 8 目标档已备好:按钮就是使用(不叫改用)'); + expect(find.textContaining('当前使用'), findsNothing); + }); + + testWidgets('识别模式分段控件:只改目标档偏好,不影响已在运行的模型', (tester) async { + final m = manager([_item(), _item(variant: kVariantN)]); + await tester.runAsync(() => m.refresh()); + await tester.runAsync(() => m.downloadModel(m.catalog.first)); // s 启用 + await pumpSection(tester, m); + + expect(find.text('高精度'), findsOneWidget, reason: '分段控件选中默认档'); + expect(find.text('高性能'), findsOneWidget); + expect(find.text('使用中'), findsOneWidget); + + await tapVisible(tester, find.text('高性能')); + // 目标档 n 尚未下载(之前只下了 s)→ 主按钮变为「下载」:仍不切运行模型 + await pumpUntilFound(tester, find.text('下载')); + await tester.pumpAndSettle(); + expect(m.mode, kVariantN, reason: '点击分段控件应改目标档'); + expect(m.isActive(7, kVariantS), isTrue, reason: '只改偏好不切运行模型'); + expect(m.models.length, 1); + expect(m.models.first.variant, kVariantS, reason: '没有热加载/切换'); + expect(find.text('下载'), findsOneWidget, reason: '卡片按钮面向新目标档'); + expect(find.textContaining('当前使用'), findsNothing); + // 目标档偏好持久化由 model_manager_test「识别目标档位偏好持久化」覆盖; // widget 测试内 tap 触发的文件写与真实 IO 交错不可控,不在本层断言重启 }); } diff --git a/flutter_app/test/model_manager_test.dart b/flutter_app/test/model_manager_test.dart index 3c42c46..9e727c5 100644 --- a/flutter_app/test/model_manager_test.dart +++ b/flutter_app/test/model_manager_test.dart @@ -153,23 +153,28 @@ void main() { expect(m2.models.first.version, 'v2.0.0'); }); - test('autoUpdate:已激活模型出新版本,refresh 后自动重下并立即生效', () async { + test('autoUpdate:已下载模型出新版本,自动补齐文件但不改变激活状态', () async { final m = manager(client([_item()])); await m.refresh(); - await m.downloadModel(m.catalog.first); + await m.downloadModel(m.catalog.first); // (7, s) v1 下载并激活 expect(downloadHits, 1); - // 重启 + 服务器目录出 v2:refresh 内部 autoUpdate 自动重下(无需手动) + // 模拟新会话(新实例 + 服务器目录出 v2):文件仍在 → autoUpdate 只补文件 final m2 = manager(client([_item(version: 'v2.0.0')])); await m2.refresh(); for (var i = 0; i < 50 && downloadHits < 2; i++) { await Future.delayed(const Duration(milliseconds: 20)); } - expect(downloadHits, 2, reason: '新版本应自动重下'); - expect(m2.isActive(7, kVariantS), isTrue, reason: '自动更新应保持激活'); - expect(m2.models.first.version, 'v2.0.0', - reason: '自动更新后立即生效新版本字节'); + expect(downloadHits, 2, reason: '新版本应自动补齐(只更新文件不激活)'); + expect(m2.isDownloaded(7, kVariantS), isTrue); + expect(m2.isActive(7, kVariantS), isFalse, + reason: '会话制:自动补齐不激活任何模型'); + expect(m2.models, isEmpty, reason: '新会话从仅预览开始'); + + // 手动启用后跑的是新版本 + await m2.setActive(7, kVariantS, true); + expect(m2.models.first.version, 'v2.0.0'); expect(m2.models.first.bytes, _modelBytes); }); @@ -189,17 +194,25 @@ void main() { expect(m2.errorOf(7, kVariantS), isNotNull); }); - test('激活集持久化:重启后恢复激活且已下载的模型', () async { + test('会话制:进入新会话清空激活,文件保留,再启用不重新下载', () async { final m = manager(client([_item()])); await m.refresh(); await m.downloadModel(m.catalog.first); + expect(m.isActive(7, kVariantS), isTrue); - // 同一 root 新建 manager 模拟重启 - final m2 = manager(client([_item()])); - await m2.refresh(); - expect(m2.isActive(7, kVariantS), isTrue, reason: '激活集应持久化'); - expect(m2.models.length, 1); - expect(downloadHits, 1, reason: '重启不应触发下载'); + // 每次进入视野页 = 新会话:清空激活与已加载模型(不恢复上次使用的模型) + m.resetForSession(); + expect(m.isActive(7, kVariantS), isFalse, + reason: '新会话不自动恢复上次使用的模型'); + expect(m.models, isEmpty); + expect(m.isDownloaded(7, kVariantS), isTrue, reason: '文件保留:清单显示已下载'); + + // 手动启用:直接用本地文件,不重新下载 + final before = downloadHits; + await m.setActive(7, kVariantS, true); + expect(m.isActive(7, kVariantS), isTrue); + expect(m.models.length, 1); + expect(downloadHits, before, reason: '已下载启用不应重新下载'); }); test('目录下线:清理本地并移除激活', () async { @@ -272,24 +285,24 @@ void main() { reason: '取消后 .part 残留应被清理'); }); - test('双档位:n 档独立目录 models/7/n/,非当前档下载不加载', () async { + test('双档位:n 档独立目录 models/7/n/,同物种只运行目标档', () async { final m = manager(client( [_item(), _item(variant: kVariantN, name: '数据集A', version: 'v1.0.0')])); await m.refresh(); expect(m.catalog.length, 2); - // 默认 s 档:下载 s 自动激活并加载 + // 默认目标档 s:下载 s 自动激活并加载 await m.downloadModel(m.catalog.first); // (7, s) expect(m.models.length, 1); expect(m.models.first.variant, kVariantS); - // 非当前档(n)下载:自动备好(激活保留),但 s 档加载列表不变 + // 同物种已有 s 在使用时下载 n:备好不启用(同物种一次只运行一档) final nItem = m.catalog.last; expect(nItem.variant, kVariantN); await m.downloadModel(nItem); expect(m.isDownloaded(7, kVariantN), isTrue); - expect(m.isActive(7, kVariantN), isTrue, reason: '下载完成应自动备好'); - expect(m.models.length, 1, reason: '非当前档模型不应进入加载列表'); + expect(m.isActive(7, kVariantN), isFalse, reason: '同物种运行中不叠加启用'); + expect(m.models.length, 1, reason: '备好的 n 档不进加载列表'); expect(m.models.first.variant, kVariantS); // n 档文件在独立子目录(目录键 = 档位),s 档仍为同级文件 @@ -298,38 +311,108 @@ void main() { expect(await File('${root.path}/7/model.tflite').exists(), isTrue, reason: 's 档 legacy 同级布局保持不变'); - // 切档:热加载 n 档已备模型,s 档不再加载(无需重新下载) + // 切目标档:只改偏好,不切换运行中的模型 final before = downloadHits; await m.setMode(kVariantN); expect(m.mode, kVariantN); - expect(downloadHits, before, reason: '切档不应触发下载'); + expect(downloadHits, before, reason: '切目标档不应触发下载'); + expect(m.isActive(7, kVariantS), isTrue, reason: '切档不改运行状态'); + expect(m.models.length, 1); + expect(m.models.first.variant, kVariantS); + + // 改用 n:同物种 s 自动停用,n 启用(无需重新下载) + await m.setActive(7, kVariantN, true); + expect(m.isActive(7, kVariantS), isFalse, reason: '改用自动停用另一档'); + expect(m.isActive(7, kVariantN), isTrue); expect(m.models.length, 1); expect(m.models.first.variant, kVariantN); expect(m.models.first.datasetName, '数据集A'); // 切回 s 档恢复 s 模型 - await m.setMode(kVariantS); + await m.setActive(7, kVariantS, true); expect(m.models.length, 1); expect(m.models.first.variant, kVariantS); + expect(m.isActive(7, kVariantN), isFalse); }); - test('识别档位持久化:重启后保持档位并加载该档已激活模型', () async { + test('识别目标档位偏好持久化;激活集为会话态不跨重启', () async { final m = manager(client( [_item(), _item(variant: kVariantN, name: '数据集A', version: 'v1.0.0')])); await m.refresh(); - await m.downloadModel(m.catalog.first); // (7, s) - await m.downloadModel(m.catalog.last); // (7, n) 备好 + await m.downloadModel(m.catalog.first); // (7, s) 下载自动激活 + await m.downloadModel(m.catalog.last); // (7, n) 备好不启用 await m.setMode(kVariantN); - // 同一 root 新建 manager 模拟重启:档位与激活集都持久化 + // 同一 root 新建 manager 模拟重启:目标档偏好持久化,激活集不恢复 final m2 = manager(client( [_item(), _item(variant: kVariantN, name: '数据集A', version: 'v1.0.0')])); await m2.refresh(); - expect(m2.mode, kVariantN, reason: '识别档位应持久化'); - expect(m2.isActive(7, kVariantS), isTrue, reason: '激活集应持久化'); - expect(m2.models.length, 1); - expect(m2.models.first.variant, kVariantN, reason: '重启后加载当前档位模型'); - expect(downloadHits, 2, reason: '重启与切档不应触发下载'); + expect(m2.mode, kVariantN, reason: '识别目标档位是用户偏好,应持久化'); + expect(m2.isActive(7, kVariantS), isFalse, reason: '激活集不跨会话恢复'); + expect(m2.isActive(7, kVariantN), isFalse); + expect(m2.models, isEmpty, reason: '重启/新会话从仅预览开始'); + expect(m2.isDownloaded(7, kVariantS), isTrue, reason: '文件保留可即点即用'); + expect(m2.isDownloaded(7, kVariantN), isTrue); + expect(downloadHits, 2, reason: '重启不应触发下载'); + }); + + test('多物种混合档位:改用一物种不影响其它物种的档位', () async { + final m = manager(client([ + _item(datasetId: 7, name: '数据集A'), + _item(datasetId: 7, variant: kVariantN, name: '数据集A'), + _item(datasetId: 8, name: '数据集B'), + _item(datasetId: 8, variant: kVariantN, name: '数据集B'), + ])); + await m.refresh(); + for (final it in m.catalog) { + await m.downloadModel(it); + } + expect(m.models.length, 2); + expect(m.isActive(7, kVariantS), isTrue); + expect(m.isActive(8, kVariantS), isTrue); + + // 只把 7 改到 n:8 保持 s —— 两物种不同档位并行运行 + await m.setActive(7, kVariantN, true); + expect(m.models.length, 2); + final byId = {for (final b in m.models) b.datasetId: b.variant}; + expect(byId, {7: kVariantN, 8: kVariantS}); + expect(m.isActive(7, kVariantS), isFalse); + expect(m.isActive(8, kVariantS), isTrue); + expect(m.isActive(8, kVariantN), isFalse); + + // 7 改回 s + await m.setActive(7, kVariantS, true); + expect(m.models.length, 2); + expect(m.models.firstWhere((b) => b.datasetId == 7).variant, kVariantS); + }); + + test('目标档决定首次下载自动启用:非目标档先落地不启用,目标档落地启用', () async { + final m = manager(client( + [_item(), _item(variant: kVariantN, name: '数据集A', version: 'v1.0.0')])); + await m.refresh(); + await m.setMode(kVariantN); // 目标高性能 + + // 先下 s(非目标档):数据集尚无档位在使用,但不应启用 s + await m.downloadModel(m.catalog.first); + expect(m.isDownloaded(7, kVariantS), isTrue); + expect(m.isActive(7, kVariantS), isFalse, reason: '非目标档落地不自动启用'); + expect(m.models, isEmpty); + + // 目标档 n 落地:自动启用(s 保持备好) + await m.downloadModel(m.catalog.last); + expect(m.isActive(7, kVariantN), isTrue); + expect(m.isActive(7, kVariantS), isFalse); + expect(m.models.length, 1); + expect(m.models.first.variant, kVariantN); + }); + + test('单档目录(无目标档条目):下载即自动启用仅有的档', () async { + final m = manager(client([_item()])); // 目录只有 s + await m.refresh(); + await m.setMode(kVariantN); // 目标高性能但目录没有 n + await m.downloadModel(m.catalog.first); + expect(m.isActive(7, kVariantS), isTrue, reason: '无目标档时启用仅有的档'); + expect(m.models.length, 1); }); test('数据集仍在但 n 档下线:清理 n 档子目录,保留数据集目录', () async { @@ -345,4 +428,70 @@ void main() { expect(await Directory('${root.path}/7').exists(), isTrue, reason: '数据集仍在(s 档):不应删整目录'); }); + + test('目录缓存:断网重启后展示上次目录、标出已下载、保留文件且不恢复激活', () async { + final m = manager(client([_item()])); + await m.refresh(); + await m.downloadModel(m.catalog.first); // (7, s) 下载并激活 + expect(m.isDownloaded(7, kVariantS), isTrue); + + // 模拟重启 + 断网:同 root 新实例、目录接口失败 + final offline = + manager(MockClient((_) async => http.Response('boom', 500))); + await offline.refresh(); + + expect(offline.catalog.length, 1, reason: '无网络也应以缓存目录展示模型清单'); + expect(offline.catalog.first.datasetId, 7); + expect(offline.error, isNotNull, reason: '未拉取成功过仍提示目录刷新失败'); + expect(offline.ready, isFalse); + expect(offline.isDownloaded(7, kVariantS), isTrue, + reason: '缓存目录同样扫描本地文件:按钮不误导为未下载'); + expect(offline.isActive(7, kVariantS), isFalse, reason: '会话制:不恢复激活'); + expect(offline.models, isEmpty); + expect(await Directory('${root.path}/7').exists(), isTrue, + reason: '离线降级(非权威目录)不得清理本地模型'); + }); + + test('目录缓存:网络成功覆盖缓存,再离线读到最近一次目录', () async { + final m = manager(client([_item(datasetId: 7, name: '数据集A')])); + await m.refresh(); // 缓存 v1:只有数据集 7 的 s 档 + + final m2 = manager(client([ + _item(datasetId: 7, name: '数据集A'), + _item(datasetId: 7, variant: kVariantN, name: '数据集A'), + _item(datasetId: 8, name: '数据集B'), + ])); + await m2.refresh(); + expect(m2.catalog.length, 3); + + final offline = + manager(MockClient((_) async => http.Response('boom', 500))); + await offline.refresh(); + expect(offline.catalog.length, 3, reason: '缓存应为最近一次成功拉取的目录'); + expect(offline.catalog.map((c) => c.datasetId).toSet(), {7, 8}); + expect( + offline.catalog + .where((c) => c.datasetId == 7) + .map((c) => c.variant) + .toSet(), + {kVariantS, kVariantN}); + }); + + test('升级后断网首启(无缓存文件):拉取失败不执行任何清理', () async { + // 旧版本下载的文件直接落盘(无 catalog.json):升级首启即断网的最坏情况 + final dir = Directory('${root.path}/9'); + await dir.create(recursive: true); + await File('${dir.path}/model.tflite').writeAsBytes(_modelBytes); + await File('${dir.path}/meta.json') + .writeAsString(jsonEncode({'version': 'v1.0.0'})); + + final offline = + manager(MockClient((_) async => http.Response('boom', 500))); + await offline.refresh(); + + expect(offline.error, isNotNull); + expect(offline.catalog, isEmpty, reason: '无缓存可兜底:无可展示目录'); + expect(await dir.exists(), isTrue, + reason: '清理只在网络拉取成功后执行,离线首启不得误删已下载模型'); + }); } diff --git a/flutter_app/test/model_merge_test.dart b/flutter_app/test/model_merge_test.dart index 8c27299..2c989d9 100644 --- a/flutter_app/test/model_merge_test.dart +++ b/flutter_app/test/model_merge_test.dart @@ -13,6 +13,20 @@ DetectionResult box(String label, double score, double x, double y, bottom: y + 0.1, modelId: modelId, modelName: modelName, + classId: 0, + ); + +DetectionResult rect(String label, double score, double l, double t, double r, + double b, {int modelId = -1}) => + DetectionResult( + label: label, + score: score, + left: l, + top: t, + right: r, + bottom: b, + modelId: modelId, + classId: 0, ); void main() { @@ -63,4 +77,35 @@ void main() { final merged = mergeAcrossModels(single, 0.45); expect(identical(merged, single), isTrue); }); + + test('不同模型偏移框(小框中心在大框内):0.45 阈值外的窗口也去重', () { + // 覆盖 ≈ 0.31 < 0.45:纯阈值会漏;小框中心在大框内 → 同一目标取高分 + // (2026-09-03:不同输入分辨率模型对同一目标的框几何系统性偏移) + final all = [ + rect('target', 0.5, 0.38, 0.445, 0.62, 0.535, modelId: 1), + rect('second', 0.6, 0.2, 0.2, 0.5, 0.5, modelId: 2), + ]; + final merged = mergeAcrossModels(all, 0.45); + expect(merged.length, 1); + expect(merged.first.label, 'second'); + expect(merged.first.score, 0.6); + }); + + test('同模型偏移框:不套用跨模型窗口,正常保留', () { + final all = [ + rect('target', 0.5, 0.38, 0.445, 0.62, 0.535, modelId: 1), + rect('second', 0.6, 0.2, 0.2, 0.5, 0.5, modelId: 1), + ]; + final merged = mergeAcrossModels(all, 0.45); + expect(merged.length, 2, reason: '同模型框对由模型内类内 NMS 负责'); + }); + + test('无来源模型(modelId -1):不套用跨模型窗口(存量语义不变)', () { + final all = [ + rect('target', 0.5, 0.38, 0.445, 0.62, 0.535), + rect('second', 0.6, 0.2, 0.2, 0.5, 0.5), + ]; + final merged = mergeAcrossModels(all, 0.45); + expect(merged.length, 2); + }); } diff --git a/server/data/observer.db b/server/data/observer.db index 58edc84..7bdb634 100644 Binary files a/server/data/observer.db and b/server/data/observer.db differ diff --git a/server/data/observer.db.bak b/server/data/observer.db.bak new file mode 100644 index 0000000..526fcff Binary files /dev/null and b/server/data/observer.db.bak differ diff --git a/server/workspace/trainings/RNPHE.tflite b/server/workspace/trainings/RNPHE.tflite index e6606bf..63263cf 100644 Binary files a/server/workspace/trainings/RNPHE.tflite and b/server/workspace/trainings/RNPHE.tflite differ diff --git a/server/workspace/trainings/RNPHE.tflite.bak b/server/workspace/trainings/RNPHE.tflite.bak new file mode 100644 index 0000000..e6606bf Binary files /dev/null and b/server/workspace/trainings/RNPHE.tflite.bak differ diff --git a/server/workspace/trainings/RNPHE_n.tflite b/server/workspace/trainings/RNPHE_n.tflite index 848480a..9a47858 100644 Binary files a/server/workspace/trainings/RNPHE_n.tflite and b/server/workspace/trainings/RNPHE_n.tflite differ diff --git a/server/workspace/trainings/RNPHE_n.tflite.bak b/server/workspace/trainings/RNPHE_n.tflite.bak new file mode 100644 index 0000000..848480a Binary files /dev/null and b/server/workspace/trainings/RNPHE_n.tflite.bak differ