1
@@ -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/<datasetId>/<variant>/`(双档位 2026-09-03,
|
||||
原无 variant 目录与存量 s 档一致——s 档复用 `models/<datasetId>/` 同级读取,
|
||||
目录键 = 档位标识符),含 `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 报
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- App 内更新安装 APK(PackageInstaller 会话安装,见 InstallerChannel.kt) -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<application
|
||||
android:label="视野"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
package com.example.observer
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* 原生 APK 安装通道(App 内更新安装):PackageInstaller 会话安装,
|
||||
* 安装进度经 EventChannel 实时回传 Flutter(下载进度由 Flutter 侧 http 流式下载自算)。
|
||||
*
|
||||
* 通道:
|
||||
* - MethodChannel "observer/installer":install(path)
|
||||
* - result.success("installing"):已提交安装
|
||||
* - result.success("permission_required"):未开「安装未知应用」,已拉起系统设置页
|
||||
* - EventChannel "observer/installer/progress":{event: progress/finished/failed, ...}
|
||||
*/
|
||||
class InstallerChannel(
|
||||
private val activity: FlutterActivity,
|
||||
private val engine: FlutterEngine,
|
||||
) {
|
||||
private val messenger = engine.dartExecutor.binaryMessenger
|
||||
private val method = MethodChannel(messenger, "observer/installer")
|
||||
private val progress = EventChannel(messenger, "observer/installer/progress")
|
||||
|
||||
private var progressSink: EventChannel.EventSink? = null
|
||||
private var activeSession: PackageInstaller.Session? = null
|
||||
|
||||
/// 安装完成轮询兜底开关:部分 ROM(如 MIUI)点确认后不回调
|
||||
/// onProgressChanged/onFinished,仅靠回调会永久停在「安装中」。
|
||||
@Volatile
|
||||
private var installSettled = false
|
||||
|
||||
fun register() {
|
||||
method.setMethodCallHandler { call: MethodCall, result: MethodChannel.Result ->
|
||||
when (call.method) {
|
||||
"install" -> install(call.argument<String>("path") ?: "", result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
progress.setStreamHandler(object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
progressSink = events
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
progressSink = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun install(path: String, result: MethodChannel.Result) {
|
||||
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<String, Any>) {
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 879 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 11 KiB |
@@ -70,24 +70,19 @@ class _StartupGateState extends State<StartupGate> {
|
||||
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<SessionStore>();
|
||||
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;
|
||||
|
||||
@@ -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<String?> readToken() async {
|
||||
if (_cachedToken != null) return _cachedToken;
|
||||
@@ -24,18 +20,6 @@ class SessionStore {
|
||||
return _cachedPhone = await _storage.read(key: _phoneKey);
|
||||
}
|
||||
|
||||
/// 用户点过「立即更新」的服务器版本号(空串 = 从未接受过更新提示)
|
||||
Future<String> readAcceptedUpdateVersion() async {
|
||||
if (_cachedAcceptedUpdate != null) return _cachedAcceptedUpdate!;
|
||||
return _cachedAcceptedUpdate =
|
||||
(await _storage.read(key: _acceptedUpdateKey)) ?? '';
|
||||
}
|
||||
|
||||
Future<void> saveAcceptedUpdateVersion(String version) async {
|
||||
_cachedAcceptedUpdate = version;
|
||||
await _storage.write(key: _acceptedUpdateKey, value: version);
|
||||
}
|
||||
|
||||
Future<void> save(String phone, String token) async {
|
||||
_cachedPhone = phone;
|
||||
_cachedToken = token;
|
||||
|
||||
@@ -47,6 +47,79 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||
/// ModelManager revision 快照:模型文件更新(自动更新下载新版)也需重建 worker
|
||||
int _lastRevision = -1;
|
||||
|
||||
/// C 端状态胶囊:3 秒内连点 5 次切换完整开发诊断(远程收截图报障仍可取数)
|
||||
bool _devInfo = false;
|
||||
final List<DateTime> _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<Widget> _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 = <Widget>[];
|
||||
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<CameraScreen> {
|
||||
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<CameraScreen> {
|
||||
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<CameraScreen> {
|
||||
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<void> _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<CameraScreen> {
|
||||
_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<CameraScreen> {
|
||||
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<CameraScreen> {
|
||||
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<CameraScreen> {
|
||||
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<CameraScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
// 模型未加载时仅显示相机预览,不做检测标注(横幅置于顶栏下方,避免与底部诊断行重叠)
|
||||
if (!vm.state.modelReady)
|
||||
// 模型未加载时仅显示相机预览,不做检测标注(横幅置于顶栏下方,避免与底部诊断行重叠);
|
||||
// worker 后台构建中不提示「加载失败」(modelReady 就绪前的一瞬)
|
||||
if (!vm.state.modelReady && !_workerPending)
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
@@ -405,56 +516,87 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||
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('授权相机')),
|
||||
],
|
||||
|
||||
@@ -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<int> _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<DetectionResult> dedupeVisibleOverlaps(List<DetectionResult> visible) {
|
||||
if (visible.length <= 1) return visible;
|
||||
final sorted = [...visible]..sort((a, b) => b.score.compareTo(a.score));
|
||||
final kept = <DetectionResult>[];
|
||||
for (final r in sorted) {
|
||||
if (!kept.any((k) => k.isSuspect == r.isSuspect && sameTarget(k, r))) {
|
||||
kept.add(r);
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 = <int, List<ModelCatalogItem>>{};
|
||||
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<String> _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<ModelCatalogItem> 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<ModelCatalogItem> items,
|
||||
required ModelCatalogItem target,
|
||||
required bool downloading,
|
||||
required bool hasError,
|
||||
required bool tActive,
|
||||
required bool tDownloaded,
|
||||
required List<ModelCatalogItem> 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -229,15 +229,12 @@ Future<void> _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<String>(),
|
||||
modelId: (e[2] as num).toInt(),
|
||||
modelName: displayName,
|
||||
modelName: name,
|
||||
);
|
||||
if (d == null) {
|
||||
failures.add(name.isEmpty ? 'unknown' : name);
|
||||
@@ -475,8 +472,25 @@ Future<void> _workerMain(SendPort mainPort) async {
|
||||
/// 实测多个模型会对同一目标检出不同类别(误检/歧义),若异类别互不压制
|
||||
/// 会出现重叠框;2026-09-01 用户实测定案:所有模型的框统一按 IoU 去重,
|
||||
/// 重叠时取高分(远处真实的多目标互不重叠,正常保留)。
|
||||
///
|
||||
/// 2026-09-03 补跨模型同目标窗口:小框被大框覆盖 > [iouThreshold] 直接去重;
|
||||
/// 覆盖不足、但两框来自**不同模型**且 [sameTarget](小框中心在大框内、
|
||||
/// 覆盖 ≥ 30%)也去重——不同输入分辨率模型对同一目标的框几何有系统性偏移,
|
||||
/// 纯阈值会漏判。同模型框对(模型内已做过类内 NMS)不套用该窗口。
|
||||
List<DetectionResult> mergeAcrossModels(
|
||||
List<DetectionResult> 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 = <DetectionResult>[];
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -35,3 +35,22 @@ List<DetectionResult> nms(List<DetectionResult> 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;
|
||||
}
|
||||
|
||||
@@ -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 兜底
|
||||
|
||||
@@ -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/<datasetId>/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<ModelKey> _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<ModelCatalogItem> 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<ModelBundle> 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<void> 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<void> _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<String, dynamic>;
|
||||
final data = body['data'] as Map<String, dynamic>? ?? const {};
|
||||
final list = data['models'] as List? ?? const [];
|
||||
_catalog = list
|
||||
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
|
||||
.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<String, dynamic>;
|
||||
final data = body['data'] as Map<String, dynamic>? ?? const {};
|
||||
final list = data['models'] as List? ?? const [];
|
||||
_catalog = list
|
||||
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
await _saveCatalogCache(list);
|
||||
fetched = true;
|
||||
} catch (e) {
|
||||
// 拉取失败:保留缓存/旧目录继续展示;本实例从未拉取成功过才记错误
|
||||
// (有缓存兜底时同样提示,说明当前展示的目录未经最新网络确认)
|
||||
if (!_ready) _error = '模型目录拉取失败:$e';
|
||||
}
|
||||
// 无缓存且未拉取成功(目录确为空):无从同步,等下次刷新
|
||||
if (_catalog.isEmpty && !fetched) return;
|
||||
|
||||
// 只拉目录不下载;扫描本地已下载(meta+文件齐备)供清单展示
|
||||
// 只拉目录不下载;扫描本地已有模型文件供清单展示。版本新旧都算已下载:
|
||||
// 旧版本文件由 autoUpdate 静默补齐,无需用户看到「下载」按钮再下。
|
||||
// 缓存目录同样扫描:离线重开也能正确标出已下载档位
|
||||
final downloaded = <ModelKey>{};
|
||||
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<bool> 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<void> 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<void> 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<void> _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<void> _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<String, dynamic>;
|
||||
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<void> _saveMode() async {
|
||||
try {
|
||||
final root = await _rootDir();
|
||||
@@ -527,11 +540,42 @@ class ModelManager extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 载入上次成功拉取的目录缓存(catalog.json,模型根目录下)——离线/弱网时
|
||||
/// 设置弹层也能先展示模型清单。无缓存文件/损坏/空列表则保持目录为空。
|
||||
Future<void> _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<String, dynamic>;
|
||||
final list = data['models'] as List? ?? const [];
|
||||
if (list.isEmpty) return;
|
||||
_catalog = list
|
||||
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('[ModelManager] 目录缓存读取失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 落盘最近一次成功拉取的 models 原始列表(含服务器可能新增的字段),
|
||||
/// 供下次离线/网络慢时先展示;缓存仅作展示降级,不参与清理/自动更新决策。
|
||||
Future<void> _saveCatalogCache(List<dynamic> 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<List<ModelBundle>> _loadBundles(
|
||||
List<ModelCatalogItem> catalog) async {
|
||||
final bundles = <ModelBundle>[];
|
||||
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/<datasetId>/`(legacy 无子目录,目录键 = 档位
|
||||
/// 标识符的 s 形态,存量设备零迁移);n 档存 `models/<datasetId>/n/`。
|
||||
/// 档位子路径(相对模型根目录):s 档 `models/<datasetId>/`(legacy 无子目录,
|
||||
/// 目录键 = 档位标识符的 s 形态,存量设备零迁移);n 档 `models/<datasetId>/n/`。
|
||||
String _subPath(int datasetId, String variant) =>
|
||||
variant == kVariantS ? '$datasetId' : '$datasetId/$variant';
|
||||
|
||||
/// 档位目录(不存在则创建)
|
||||
Future<Directory> _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<bool> _hasFile(ModelCatalogItem item) async {
|
||||
final root = await _rootDir();
|
||||
return File('${root.path}/${_subPath(item.datasetId, item.variant)}/model.tflite')
|
||||
.exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> _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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> install(String path) =>
|
||||
_method.invokeMethod<String>('install', {'path': path}).then(
|
||||
(v) => v ?? 'installing',
|
||||
);
|
||||
|
||||
/// 安装进度流:progress(0-100) → finished(success) / failed(error)
|
||||
static Stream<InstallEvent> progress() {
|
||||
return _progress.receiveBroadcastStream().map((e) {
|
||||
final m = e as Map;
|
||||
return InstallEvent(
|
||||
event: m['event'] as String? ?? '',
|
||||
progress: (m['progress'] as num?)?.toInt() ?? 0,
|
||||
success: m['success'] as bool?,
|
||||
error: m['error'] as String?,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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<UpdateScreen> createState() => _UpdateScreenState();
|
||||
}
|
||||
|
||||
enum _Stage { idle, downloading, installing, finished, failed }
|
||||
|
||||
class _UpdateScreenState extends State<UpdateScreen> {
|
||||
final http.Client _client = http.Client();
|
||||
final File _apkFile = File('${Directory.systemTemp.path}/observer-latest.apk');
|
||||
final File _apkPart =
|
||||
File('${Directory.systemTemp.path}/observer-latest.apk.part');
|
||||
|
||||
_Stage _stage = _Stage.idle;
|
||||
|
||||
/// 进度百分比 0-100;null = 总量未知(不确定进度条)
|
||||
double? _progress;
|
||||
String? _message;
|
||||
StreamSubscription<InstallEvent>? _installSub;
|
||||
|
||||
/// 安装超时兜底:确认框未处理/系统无回调时避免永久卡「安装中」
|
||||
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<void> _launch() async {
|
||||
if (_stage == _Stage.downloading ||
|
||||
_stage == _Stage.installing ||
|
||||
_stage == _Stage.finished) {
|
||||
Future<void> _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<void> _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<void> _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<UpdateScreen> {
|
||||
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<UpdateScreen> {
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String> alerts = [];
|
||||
@override
|
||||
void onDetected(String label) => alerts.add(label);
|
||||
}
|
||||
|
||||
void feed(CameraViewModel vm, List<DetectionResult> 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<void>.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<void>.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
expect(reminder.alerts, ['雉鸡'],
|
||||
reason: '补挂进原轨迹:提醒按轨迹原标签只触发一次;若新建第二条轨迹'
|
||||
'(斑鸠未达 500ms 显示窗)本帧不会触发提醒');
|
||||
expect(vm.state.results.length, 1, reason: '同位置只有一条轨迹可见');
|
||||
expect(vm.state.results.single.label, '斑鸠', reason: '框内容跟随后到的检测');
|
||||
});
|
||||
}
|
||||
@@ -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 交错不可控,不在本层断言重启
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<void>.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: '清理只在网络拉取成功后执行,离线首启不得误删已下载模型');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||