164 lines
11 KiB
Markdown
164 lines
11 KiB
Markdown
# observer
|
||
|
||
动物实时识别 App(Flutter 版)。Android / iOS 一套代码,后端接口与支付见
|
||
[`docs/PaymentApi.md`](docs/PaymentApi.md)。
|
||
|
||
## Android 打包
|
||
|
||
```bash
|
||
./build_apk.sh # 用当前 pubspec 版本构建,产物 build/app/outputs/flutter-apk/observer-x.y.z.apk
|
||
./build_apk.sh --bump # 自动递增 patch+1、build+1 后构建
|
||
./build_apk.sh 1.0.7 # 用指定版本(versionName)构建,build+1
|
||
```
|
||
|
||
产物命名 `observer-x.y.z.apk`(管理端上传版本号从文件名识别);构建的
|
||
`app-release.apk` 中间产物由脚本清理,只保留规范命名文件。
|
||
|
||
## iOS 真机部署(iPhone)
|
||
|
||
### 构建与安装
|
||
|
||
```bash
|
||
# 生产包直接构建即可:默认 API_BASE_URL 为线上域名(lib/config/app_config.dart),无需传参
|
||
flutter build ios --release
|
||
# 仅本地联调(后端跑在 Mac 上、手机连同一 Wi-Fi)时才覆盖为 Mac 局域网 IP:
|
||
# flutter build ios --release --dart-define=API_BASE_URL=http://<Mac局域网IP>:8080
|
||
|
||
# 安装到真机(UDID 可用 `xcrun devicectl list devices` 查询)
|
||
xcrun devicectl device install app --device <UDID> build/ios/iphoneos/Runner.app
|
||
|
||
# 启动并抓控制台日志(--terminate-existing 先杀掉旧实例)
|
||
xcrun devicectl device process launch --console --terminate-existing \
|
||
--device <UDID> com.observer.app
|
||
```
|
||
|
||
### 注意事项(踩过的坑)
|
||
|
||
- **debug 构建不能在真机上从桌面图标启动**:iOS 14+ 会提示
|
||
"In iOS 14+, debug mode Flutter apps can only be launched from Flutter tooling"。
|
||
debug 调试必须用 `flutter run -d <设备ID>` 或 Xcode IDE 启动(`flutter devices` 查设备ID);
|
||
从图标启动只对 release 构建有效。
|
||
- **`flutter run` 真机 debug 附接失败(errno 49,多次复现)**:Xcode 构建、安装、
|
||
启动都成功,attach 阶段报 `OS Error: Can't assign requested address, errno = 49`
|
||
工具即退出——与本机 VPN(utun 隧道)环境相关,断 VPN 后可恢复。
|
||
需要真机验证时一律用上方 release + devicectl 流程(不依赖 attach);只有需要
|
||
热重载/看 debugPrint 才用 `flutter run`,遇 errno 49 先断 VPN 重试。
|
||
## 端侧推理加速(GPU / CoreML)
|
||
|
||
识别慢的根因是 yolov8s@1280 推理量大(CPU 4 线程约每秒不到 1 帧),
|
||
`TfliteDetector.fromBuffer` 加载模型时按平台挂加速 delegate,均为**浮点计算不降精度**
|
||
(区别于 int8 量化掉点):
|
||
|
||
| 平台 | delegate | 说明 |
|
||
|---|---|---|
|
||
| Android | `GpuDelegateV2` | TFLite GPU delegate;依赖 `libtensorflowlite_gpu_jni.so`,已 vendor 到 `android/app/src/main/jniLibs/arm64-v8a/`(AAR 因 AGP 9 namespace 冲突保持排除,升级 tflite_flutter 时需同步换 .so,版本对齐 base 2.11.0) |
|
||
| iOS | `CoreMlDelegate` | Core ML(苹果 ANE/GPU,插件 pod 自带 TensorFlowLiteSwift/CoreML,无需额外依赖) |
|
||
|
||
delegate 初始化失败(老设备/驱动/符号缺失)**自动回退纯 CPU 4 线程**,最后才返回 null
|
||
(仅预览不识别)。生效与否看日志:加载模型时输出
|
||
`[TfliteDetector] 加速生效 model=xxx (CoreML|GPU)`,回退输出 `回退 CPU` 及原因。
|
||
GPU delegate 默认允许 FP16 计算(YOLO 类精度损失可忽略);如需全精度改为传
|
||
`GpuDelegateOptionsV2(isPrecisionLossAllowed: false)`。
|
||
|
||
## 模型热更新(多数据集模型)
|
||
|
||
模型与 APK 更新走**独立通道**:进入视野页时后台同步 `GET /api/v1/app/update`
|
||
随附的 `models` 目录(公开接口,无需登录;不阻塞相机预览启动),与
|
||
`UpdateChecker` 的 APK 检查并行。
|
||
|
||
- **目录条目**:`{datasetId, datasetName, variant, version, labels[], sizeBytes, sha256,
|
||
downloadUrl, coverUrl}`——**双档位(2026-09-03)**:每数据集至多 2 条 = 高精度 s
|
||
(@1280 精度优先,默认)+ 高性能 n(@704 速度优先)各自的当前版本,条目带 `variant`
|
||
(s/n);服务器未发布模型时不返回 `models` 字段,App 无模型可用,
|
||
相机页仅预览不识别。
|
||
- **下载入口**:相机页设置弹层「模型清单」按需下载/使用(封面缩略图 2 列网格,
|
||
**同一物种合并一张卡**,卡上**不显示版本号、也不设档位状态行**
|
||
(2026-09-10 删除「高精度/高性能 + 版本」灰字行,版本仅作内部记账,
|
||
档位状态看主按钮);点
|
||
「下载」一次性补下该动物高精度+高性能两个已训练文件——各档独立进度逐条展示、
|
||
可取消(已下过的档不重复下),**目标档落地即自动启用、伴档备好**;目标档已
|
||
启用显示「使用中」点击取消使用。弹层高度上限 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`;每次刷新先载入缓存、展示立即可用(弹层离线/弱网也有内容),
|
||
网络成功后再以权威目录覆盖缓存。清理与自动更新只在网络拉取成功时执行:
|
||
离线降级不清文件、不触发下载——升级后断网首启不会误删已下载模型。
|
||
- **清理**:服务器下线的数据集下次同步时删除本地对应目录(两档都无条目时才删)。
|
||
- **识别会话制(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/camera/model_catalog_section.dart`(清单网格:按数据集合并卡片、
|
||
双档下载与「使用/使用中/下载」主按钮动作,2026-09-03 简化文案)。
|
||
|
||
- **模型输入是 NHWC**:训练导出的模型需做字节级手术(开头 TRANSPOSE→RESHAPE,
|
||
输入 [1,320,320,3])再发布给 App,否则 iOS 报
|
||
"Node number 0 (TRANSPOSE) failed to prepare"。
|
||
- **模拟器黑屏**:本机 iOS 模拟器 Impeller 渲染黑屏,验证 UI 用 VM service
|
||
(`flutter run` 输出里的 DevTools 地址),或直接真机验证。
|
||
|
||
## 识别链路约束(踩过的坑)
|
||
|
||
### 归一化坐标不得与像素量纲阈值直接比较(2026-09-12 漏检根因)
|
||
|
||
**现象**:验证图 `RNPHE_2030`(麦田平卧雉鸡)离线推理 0.8845、与真值 IoU 0.88,
|
||
App 端对准画面却一个框都不出;换张图/换个握持方向又偶尔能出——"有时能识别、
|
||
有时识别不出来"。
|
||
|
||
**根因**:`camera_view_model.dart` 的 `_plausible()` 拿 `w / h` 与固定窗口
|
||
`0.3 ~ 3.0` 比较,但 `left/top/right/bottom` 是**按各自轴归一化**的(w 除以图宽、
|
||
h 除以图高),于是:
|
||
|
||
```
|
||
归一化宽高比 = 像素宽高比 × (图高 / 图宽)
|
||
```
|
||
|
||
竖屏画幅(图高/图宽 ≈ 1.78)下 `0.3 ~ 3.0` 实际只剩像素宽高比 `0.17 ~ 1.69`;
|
||
雉鸡是长尾鸟(本例 199×89px,像素宽高比 2.24),归一化后 3.99 > 3.0 →
|
||
**在建轨迹之前被 `continue` 丢掉,置信度再高也不显示**。横屏时窗口是 0.53~5.33
|
||
所以能出框——这就是"有时能、有时不能"随握持方向/目标姿态变化的来源。
|
||
|
||
**修法(2026-09-12 定案)**:几何门**整体删除**(过近/过远目标同样不能丢),
|
||
只保留"零宽/零高"的退化数据兜底;质量交给置信度(minScore)+ VisualPrior
|
||
(仅作用于 < 0.35 的框)+ 轨迹确认(3 帧 / ≥ 0.35 / 运动证据)把关。
|
||
|
||
**规矩(改识别链路时照做)**:
|
||
|
||
1. 任何宽高比/尺寸判断必须先换算回**像素空间**再比阈值:
|
||
`aspectPx = (w * frameW) / (h * frameH)`。直接拿 `w/h` 比固定常量,在非方图上必然错。
|
||
2. 显示链路上会**静默丢弃真框**的过滤(`continue` 不留痕)必须满足其一:
|
||
只作用于低分框(同 VisualPrior 的分工)、或有可观测口径(诊断层/日志)——
|
||
否则"模型检出了但没显示"无从定位。
|
||
3. 回归用例已锁死:`test/camera_view_model_test.dart` 的「平卧长条框不被几何过滤」
|
||
与「近距离大目标与远距离小目标均可显示」——两条在旧逻辑下必然失败,动这块先跑它们。
|
||
|
||
**定位手法**:诊断层(状态胶囊 3 秒内连点 5 次)看「最高分」——
|
||
**高分无框 = 卡在过滤链路;最高分也低 = 卡在采集/推理链路**。
|