diff --git a/flutter_app/README.md b/flutter_app/README.md index 1c52380..f998e12 100644 --- a/flutter_app/README.md +++ b/flutter_app/README.md @@ -3,6 +3,17 @@ 动物实时识别 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) ### 构建与安装 diff --git a/flutter_app/build_apk.sh b/flutter_app/build_apk.sh new file mode 100755 index 0000000..1281dfa --- /dev/null +++ b/flutter_app/build_apk.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# 打包 release APK,产物命名 observer-x.y.z.apk(管理端上传版本号从文件名识别)。 +# 用法: +# build_apk.sh 用当前 pubspec 版本构建 +# build_apk.sh --bump 自动递增 patch+1、build+1 后构建 +# build_apk.sh 1.0.7 用指定版本(versionName)构建, build+1 +set -euo pipefail +cd "$(dirname "$0")" + +CUR=$(grep -m1 '^version:' pubspec.yaml | awk '{print $2}') +VER=${CUR%+*} +BLD=${CUR#*+} + +case "${1:-}" in + "") + ;; + --bump) + VER=$(awk -F. '{print $1"."$2"."($3+1)}' <<<"$VER") + BLD=$((BLD + 1)) + ;; + *) + VER="$1" + BLD=$((BLD + 1)) + ;; +esac + +NEW="$VER+$BLD" +if [ "$NEW" != "$CUR" ]; then + sed -i '' "s/^version: .*/version: $NEW/" pubspec.yaml + echo "版本号: $CUR -> $NEW" +fi + +flutter build apk --release + +APK="build/app/outputs/flutter-apk/observer-${VER}.apk" +mv -f build/app/outputs/flutter-apk/app-release.apk "$APK" +rm -f build/app/outputs/apk/release/app-release.apk +echo "APK: $APK" diff --git a/flutter_app/lib/camera/camera_screen.dart b/flutter_app/lib/camera/camera_screen.dart index 0e4bd89..ff31545 100644 --- a/flutter_app/lib/camera/camera_screen.dart +++ b/flutter_app/lib/camera/camera_screen.dart @@ -12,6 +12,7 @@ import 'app_camera_controller.dart'; import 'camera_view_model.dart'; import 'detection_overlay.dart'; import 'frame_analyzer.dart'; +import 'model_catalog_section.dart'; /// 主界面:相机预览 + 检测框 overlay + 顶栏(返回/切换摄像头) class CameraScreen extends StatefulWidget { @@ -40,50 +41,61 @@ class _CameraScreenState extends State { void _openSettings() { final vm = _viewModel; if (vm == null) return; + ModelManager.instance.refresh(); showModalBottomSheet( context: context, + isScrollControlled: true, backgroundColor: Colors.black87, builder: (ctx) => StatefulBuilder( - builder: (ctx, setSheetState) => Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('识别设置', - style: TextStyle( - color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), - const SizedBox(height: 12), - Row( - children: [ - const Text('置信度阈值', - style: TextStyle(color: Colors.white70, fontSize: 14)), - const Spacer(), - Text('${(_minScore * 100).toStringAsFixed(0)}%', - style: const TextStyle( - color: Colors.greenAccent, - fontSize: 14, - fontWeight: FontWeight.bold)), - ], - ), - Slider( - value: _minScore, - min: 0.05, - max: 0.50, - divisions: 45, - activeColor: Colors.greenAccent, - onChanged: (v) { - setSheetState(() => _minScore = v); - _analyzer?.worker?.setMinScore(v); - }, - ), - const SizedBox(height: 8), - const Text( - '阈值越低识别越灵敏(低分框越多,误报也可能增加);' - '环颈雉鸡模型置信度普遍在 10%~20%,场景识别不到时可适当调低。', - style: TextStyle(color: Colors.white54, fontSize: 12), - ), - ], + builder: (ctx, setSheetState) => 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)}%', + style: const TextStyle( + color: Colors.greenAccent, + fontSize: 14, + fontWeight: FontWeight.bold)), + ], + ), + Slider( + value: _minScore, + min: 0.05, + max: 0.50, + divisions: 45, + activeColor: Colors.greenAccent, + onChanged: (v) { + setSheetState(() => _minScore = v); + _analyzer?.worker?.setMinScore(v); + }, + ), + const SizedBox(height: 8), + const Text( + '阈值越低识别越灵敏(低分框越多,误报也可能增加);' + '环颈雉鸡模型置信度普遍在 10%~20%,场景识别不到时可适当调低。', + style: TextStyle(color: Colors.white54, fontSize: 12), + ), + const SizedBox(height: 16), + const Divider(color: Colors.white12), + const SizedBox(height: 8), + ModelCatalogSection(manager: ModelManager.instance), + ], + ), ), ), ), diff --git a/flutter_app/lib/camera/model_catalog_section.dart b/flutter_app/lib/camera/model_catalog_section.dart new file mode 100644 index 0000000..0ec63f9 --- /dev/null +++ b/flutter_app/lib/camera/model_catalog_section.dart @@ -0,0 +1,225 @@ +import 'package:flutter/material.dart'; + +import '../models/model_manager.dart'; + +/// 设置弹层「模型清单」区块:2 列封面缩略图网格。 +/// 未下载 →「使用」点击后显示下载进度,完成自动激活;已下载未激活 → 直接激活; +/// 已激活 → 再次点击取消;下载失败 → 失败提示 + 重试。 +class ModelCatalogSection extends StatelessWidget { + final ModelManager manager; + + const ModelCatalogSection({super.key, required this.manager}); + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: manager, + builder: (context, _) { + final items = manager.catalog; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + 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), + ), + ], + ), + const SizedBox(height: 4), + if (manager.error != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(manager.error!, + style: const TextStyle( + color: Colors.orange, fontSize: 12)), + ), + if (items.isEmpty) + const Text('暂无已发布模型', + style: TextStyle(color: Colors.white54, fontSize: 13)) + else + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.72, + ), + itemCount: items.length, + itemBuilder: (context, i) => + _ModelCard(item: items[i], manager: manager), + ), + ], + ); + }, + ); + } +} + +class _ModelCard extends StatelessWidget { + final ModelCatalogItem item; + final ModelManager manager; + + const _ModelCard({required this.item, required this.manager}); + + @override + Widget build(BuildContext context) { + final active = manager.isActive(item.datasetId); + final downloaded = manager.isDownloaded(item.datasetId); + final progress = manager.progressOf(item.datasetId); + final error = manager.errorOf(item.datasetId); + + final thumb = ClipRRect( + borderRadius: BorderRadius.circular(8), + child: AspectRatio( + aspectRatio: 4 / 3, + child: Image.network( + '${manager.baseUrl}${item.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)))), + errorBuilder: (context, error, stack) => Container( + color: Colors.white12, + child: const Icon(Icons.image_not_supported_outlined, + color: Colors.white38), + ), + ), + ), + ); + + 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), + 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) { + action = SizedBox( + height: 30, + child: OutlinedButton( + onPressed: () => manager.setActive(item.datasetId, false), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.greenAccent, + side: const BorderSide(color: Colors.greenAccent)), + child: const Text('已使用', style: TextStyle(fontSize: 12)), + ), + ); + } else if (downloaded) { + action = SizedBox( + height: 30, + child: FilledButton( + onPressed: () => manager.setActive(item.datasetId, 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: const Text('使用', style: TextStyle(fontSize: 12)), + ), + ); + } + + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white10, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: Center(child: thumb)), + const SizedBox(height: 6), + Text(item.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)), + const SizedBox(height: 6), + action, + ], + ), + ); + } +} diff --git a/flutter_app/lib/models/model_manager.dart b/flutter_app/lib/models/model_manager.dart index ddccbdd..b35cf2c 100644 --- a/flutter_app/lib/models/model_manager.dart +++ b/flutter_app/lib/models/model_manager.dart @@ -81,6 +81,7 @@ class ModelManager extends ChangeNotifier { final Set _downloadedIds = {}; final Map _progress = {}; final Map _errors = {}; + final Set _cancelRequested = {}; bool _activeLoaded = false; bool _ready = false; bool _refreshing = false; @@ -104,6 +105,12 @@ class ModelManager extends ChangeNotifier { /// 下载失败原因(失败后可重试) String? errorOf(int datasetId) => _errors[datasetId]; + /// 中断进行中的下载:下一个数据块到达时终止(丢弃 .part),卡片恢复「使用」。 + /// 取消不记错误,可再次下载。 + void cancelDownload(int datasetId) { + _cancelRequested.add(datasetId); + } + ModelManager._({String? baseUrl, http.Client? client}) : this(baseUrl: baseUrl, client: client); @@ -209,11 +216,13 @@ class ModelManager extends ChangeNotifier { // 并发保护:同一数据集已有进行中的下载则直接短路(预置 0 先占位, // 使 onProgress 首次回调前的双击/refresh 交错也被 containsKey 拦下) if (_progress.containsKey(item.datasetId)) return false; + _cancelRequested.remove(item.datasetId); _progress[item.datasetId] = 0; final dir = await _modelDir(item.datasetId); final file = File('${dir.path}/model.tflite'); try { for (var attempt = 0; attempt < 2; attempt++) { + if (_cancelRequested.contains(item.datasetId)) break; final ok = await _downloadAndVerify(item, dir, file, onProgress: (r, t) { _progress[item.datasetId] = t == 0 ? 0 : r / t; @@ -227,17 +236,39 @@ class ModelManager extends ChangeNotifier { await setActive(item.datasetId, true); return true; } + if (_cancelRequested.contains(item.datasetId)) break; await file.delete().catchError((_) => file); await File('${dir.path}/model.tflite.part') .delete() .catchError((_) => file); } + if (_cancelRequested.contains(item.datasetId)) { + // 用户取消:清理残留,不记错误 + await file.delete().catchError((_) => file); + await File('${dir.path}/model.tflite.part') + .delete() + .catchError((_) => file); + _progress.remove(item.datasetId); + notifyListeners(); + debugPrint('[ModelManager] 下载已取消: ${item.datasetName}'); + return false; + } _progress.remove(item.datasetId); _errors[item.datasetId] = '下载失败,请重试'; notifyListeners(); debugPrint('[ModelManager] 下载失败: ${item.datasetName} ${item.version}'); return false; } catch (e) { + if (_cancelRequested.contains(item.datasetId)) { + await file.delete().catchError((_) => file); + await File('${dir.path}/model.tflite.part') + .delete() + .catchError((_) => file); + _progress.remove(item.datasetId); + notifyListeners(); + debugPrint('[ModelManager] 下载已取消: ${item.datasetName}'); + return false; + } _progress.remove(item.datasetId); _errors[item.datasetId] = '下载异常:$e'; notifyListeners(); @@ -262,10 +293,15 @@ class ModelManager extends ChangeNotifier { } final total = res.contentLength ?? item.sizeBytes; await for (final chunk in res.stream) { + if (_cancelRequested.contains(item.datasetId)) break; // 用户取消 sink.add(chunk); received += chunk.length; onProgress?.call(received, total); } + if (_cancelRequested.contains(item.datasetId)) { + await sink.close(); + return false; + } await sink.close(); final bytes = await part.readAsBytes(); final hex = sha256.convert(bytes).toString(); diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml index 66d5773..445400f 100644 --- a/flutter_app/pubspec.yaml +++ b/flutter_app/pubspec.yaml @@ -2,7 +2,7 @@ name: observer description: "视野 - 动物实时识别 (环颈雉鸡/生境), YOLOv8 + 充值付费" publish_to: 'none' -version: 1.0.6+7 +version: 1.0.7+8 environment: sdk: ^3.12.2 diff --git a/flutter_app/test/model_catalog_section_test.dart b/flutter_app/test/model_catalog_section_test.dart new file mode 100644 index 0000000..c99403f --- /dev/null +++ b/flutter_app/test/model_catalog_section_test.dart @@ -0,0 +1,150 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart' show sha256; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:observer/camera/model_catalog_section.dart'; +import 'package:observer/models/model_manager.dart'; + +const _modelBytes = [1, 2, 3, 4, 5, 6, 7, 8]; +String _shaHex(List bytes) => sha256.convert(bytes).toString(); + +/// 分两段到达的下载流(中间 50ms 停顿,供取消测试在下载中触发) +Stream> _delayedChunks() async* { + yield [1, 2, 3, 4]; + await Future.delayed(const Duration(milliseconds: 50)); + yield [5, 6, 7, 8]; +} + +Map _item() => { + 'datasetId': 7, + 'datasetName': '环颈雉鸡数据集', + 'version': 'v1.0.0', + 'labels': ['pheasant', 'suspect'], + 'sizeBytes': _modelBytes.length, + 'sha256': _shaHex(_modelBytes), + 'downloadUrl': '/download/models/7/latest.tflite', + 'coverUrl': '/api/v1/app/cover?namePrefix=RNPHE', + }; + +void main() { + late Directory root; + + setUp(() async { + root = await Directory.systemTemp.createTemp('catalog-section-test'); + }); + + tearDown(() => root.delete(recursive: true)); + + ModelManager manager() => ModelManager( + baseUrl: 'http://test.local', + client: MockClient((req) async { + if (req.url.path == '/api/v1/app/update') { + return http.Response.bytes( + utf8.encode(jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': {'models': [_item()]} + })), + 200); + } + if (req.url.path.startsWith('/download/models/')) { + return http.Response.bytes(_modelBytes, 200); + } + return http.Response('not found', 404); + }), + rootDir: () async => root, + ); + + Future pumpSection(WidgetTester tester, ModelManager m) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: ModelCatalogSection(manager: m)))); + await tester.pumpAndSettle(); + } + + testWidgets('未下载:显示使用按钮,点击后下载完成自动变为已使用', (tester) async { + final m = manager(); + await m.refresh(); + await pumpSection(tester, m); + + expect(find.text('环颈雉鸡数据集'), findsOneWidget); + expect(find.text('使用'), findsOneWidget); + + await tester.tap(find.text('使用')); + await tester.pumpAndSettle(); + + expect(m.isDownloaded(7), isTrue); + expect(m.isActive(7), isTrue); + expect(find.text('已使用'), findsOneWidget); + }); + + testWidgets('已激活:再次点击取消使用', (tester) async { + final m = manager(); + await m.refresh(); + await m.downloadModel(m.catalog.first); + await pumpSection(tester, m); + + expect(find.text('已使用'), findsOneWidget); + await tester.tap(find.text('已使用')); + await tester.pumpAndSettle(); + + expect(m.isActive(7), isFalse); + expect(find.text('使用'), findsOneWidget); + }); + + testWidgets('目录为空:显示暂无已发布模型', (tester) async { + final m = ModelManager( + baseUrl: 'http://test.local', + client: MockClient((_) async => http.Response.bytes( + utf8.encode(jsonEncode( + {'code': 0, 'message': 'ok', 'data': {'models': []}})), + 200)), + rootDir: () async => root, + ); + await m.refresh(); + await pumpSection(tester, m); + + expect(find.text('暂无已发布模型'), findsOneWidget); + }); + + testWidgets('下载中:显示进度和取消按钮,取消后恢复使用', (tester) async { + final m = ModelManager( + baseUrl: 'http://test.local', + client: MockClient((req) async { + if (req.url.path == '/api/v1/app/update') { + return http.Response.bytes( + utf8.encode(jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': {'models': [_item()]} + })), + 200); + } + if (req.url.path.startsWith('/download/models/')) { + return http.Response.fromStream(http.StreamedResponse( + _delayedChunks(), 200, + contentLength: _modelBytes.length)); + } + return http.Response('not found', 404); + }), + rootDir: () async => root, + ); + await m.refresh(); + await pumpSection(tester, m); + + await tester.tap(find.text('使用')); + await tester.pump(); // 首个分块到达,进度条与取消按钮出现 + expect(find.text('取消'), findsOneWidget); + + await tester.tap(find.text('取消')); + await tester.pumpAndSettle(); + + expect(m.isDownloaded(7), isFalse); + expect(m.isActive(7), isFalse); + expect(find.text('使用'), findsOneWidget); + }); +} diff --git a/flutter_app/test/model_manager_test.dart b/flutter_app/test/model_manager_test.dart index 5520465..1b0ab23 100644 --- a/flutter_app/test/model_manager_test.dart +++ b/flutter_app/test/model_manager_test.dart @@ -11,6 +11,13 @@ const _modelBytes = [1, 2, 3, 4, 5, 6, 7, 8]; String _shaHex(List bytes) => sha256.convert(bytes).toString(); +/// 分两段到达的下载流(中间 50ms 停顿,供取消测试在下载中触发) +Stream> _delayedChunks() async* { + yield [1, 2, 3, 4]; + await Future.delayed(const Duration(milliseconds: 50)); + yield [5, 6, 7, 8]; +} + Map _catalog(List> models) => { 'code': 0, 'message': 'ok', @@ -194,4 +201,36 @@ void main() { expect(broken.ready, isFalse); expect(broken.error, isNotNull); }); + + test('取消下载:中断后恢复未下载、不记错误', () async { + final m = manager(MockClient((req) async { + if (req.url.path == '/api/v1/app/update') { + return http.Response.bytes( + utf8.encode(jsonEncode(_catalog([_item()]))), 200); + } + if (req.url.path.startsWith('/download/models/')) { + downloadHits++; + return http.Response.fromStream(http.StreamedResponse( + _delayedChunks(), 200, + contentLength: _modelBytes.length)); + } + return http.Response('not found', 404); + })); + await m.refresh(); + + final fut = m.downloadModel(m.catalog.first); + // 首个分块到达后取消(模拟用户在下载中点取消) + await Future.delayed(const Duration(milliseconds: 20)); + m.cancelDownload(7); + final ok = await fut; + + expect(ok, isFalse); + expect(downloadHits, 1); + expect(m.isDownloaded(7), isFalse); + expect(m.isActive(7), isFalse); + expect(m.progressOf(7), isNull); + expect(m.errorOf(7), isNull, reason: '取消不记错误'); + expect(await File('${root.path}/7/model.tflite.part').exists(), isFalse, + reason: '取消后 .part 残留应被清理'); + }); } diff --git a/flutter_app/test/scratch_diag_test.dart b/flutter_app/test/scratch_diag_test.dart new file mode 100644 index 0000000..c95aa5a --- /dev/null +++ b/flutter_app/test/scratch_diag_test.dart @@ -0,0 +1,83 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart' show sha256; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:observer/camera/model_catalog_section.dart'; +import 'package:observer/models/model_manager.dart'; + +const _modelBytes = [1, 2, 3, 4, 5, 6, 7, 8]; +String _shaHex(List bytes) => sha256.convert(bytes).toString(); + +Map _item() => { + 'datasetId': 7, + 'datasetName': '环颈雉鸡数据集', + 'version': 'v1.0.0', + 'labels': ['pheasant', 'suspect'], + 'sizeBytes': _modelBytes.length, + 'sha256': _shaHex(_modelBytes), + 'downloadUrl': '/download/models/7/latest.tflite', + 'coverUrl': '/api/v1/app/cover?namePrefix=RNPHE', + }; + +void main() { + late Directory root; + + setUp(() async { + root = await Directory.systemTemp.createTemp('catalog-diag'); + }); + + tearDown(() => root.delete(recursive: true)); + + ModelManager manager() => ModelManager( + baseUrl: 'http://test.local', + client: MockClient((req) async { + if (req.url.path == '/api/v1/app/update') { + return http.Response.bytes( + utf8.encode(jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': {'models': [_item()]} + })), + 200); + } + if (req.url.path.startsWith('/download/models/')) { + return http.Response.bytes(_modelBytes, 200); + } + return http.Response('not found', 404); + }), + rootDir: () async => root, + ); + + testWidgets('diag step marker', (tester) async { + print('=== BEFORE manager()'); + final m = manager(); + print('=== BEFORE refresh()'); + await m.refresh(); + print('=== AFTER refresh() ready=${m.ready} catalog=${m.catalog.length}'); + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: ModelCatalogSection(manager: m)))); + print('=== AFTER pumpWidget'); + for (var i = 0; i < 15; i++) { + await tester.pump(const Duration(milliseconds: 200)); + final spinners = find.byType(CircularProgressIndicator).evaluate().length; + final icons = find.byIcon(Icons.image_not_supported_outlined).evaluate().length; + print('pump $i scheduled=${tester.binding.hasScheduledFrame} ' + 'spinner=$spinners errIcon=$icons'); + } + print('=== bounded pumpAndSettle attempt'); + try { + await tester.pumpAndSettle( + const Duration(milliseconds: 100), + EnginePhase.sendSemanticsUpdate, + const Duration(seconds: 5)); + print('=== pumpAndSettle OK'); + } catch (e) { + print('=== pumpAndSettle TIMEOUT: $e'); + } + print('=== DONE'); + }); +} diff --git a/server/Dockerfile b/server/Dockerfile index ce5f5e2..6fb9613 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -8,7 +8,9 @@ ENV GOPROXY=${GOPROXY} WORKDIR /build # 依赖层缓存:go.mod/go.sum 变更才重拉依赖 COPY go.mod go.sum ./ -RUN go mod download +# 清空 buildkit 注入的宿主代理(Docker Desktop 全局代理 127.0.0.1:7892 在容器内不通, +# proxyconnect connection refused);依赖直连 goproxy.cn 拉取,后续 go build 离线无需网络 +RUN http_proxy= https_proxy= HTTP_PROXY= HTTPS_PROXY= go mod download COPY . . RUN CGO_ENABLED=0 go build -o /out/observer-server . diff --git a/server/README.md b/server/README.md index c5097b2..2d5d46a 100644 --- a/server/README.md +++ b/server/README.md @@ -14,7 +14,7 @@ | 套餐 | `config.yml` `plans` 节点配置三档套餐(改价 = 改配置重启),价格**整数分**(1000 / 5600 / 18000) | | 后台管理端 | `server_admin/`(Vue3 + Element Plus)管理页面:订单查询、账号/授权管理(手动授权/撤销)、App 版本管理;构建产物由后端 `/admin/` 托管,登录页输入 token 后以 `X-Admin-Token` 头鉴权(`config.yml admin.token`) | | 版本管理 | 后台管理端上传 Android APK + 更新说明,APK 存服务器 `app.apkDir`(默认 `./workspace/`,与 `./data` 平级、挂载持久化)**固定文件名 `observer-latest.apk`,上传即覆盖,目录永远只保留最新一个文件**;**版本号从文件名识别**:文件须命名为 `observer-x.y.z.apk`(Flutter 打包产物即此命名,版本号取自 pubspec);客户端启动时 `GET /api/v1/app/update` 检查更新:服务器版本高于本地版本即弹更新提示(不可跳过)。**仅 Android 检查,iOS 不做版本下发**(iOS 走 App Store 自行更新)。版本记录可删除:删最新版本联动删除 APK 文件,删历史版本仅删记录 | -| 数据训练(唯一入口) | 后台管理端「数据训练」一个菜单承载数据集全流程:**数据集卡片列表**(封面图/描述/图片数/已标注数/**训练状态徽标**),**卡片下方直接展示训练任务进度条与状态**(无独立训练页);详情页为**图片与标注一体视图**:分页(每页 20 条)逐行「原图 ‖ 标注图」对照展示;**图片入库(手动上传/AI 生成)自动触发 RF-DETR 全图扫描标注**,进度条展示在页顶;页顶另有「全量标注」按钮可手动重标全部图片(覆盖各图已有标注);点击原图/标注图弹窗放大,弹窗为**审核视图(不做手动画框)**:点击框选中,列表可确认疑似框/删除误检框/清空并保存——AI 自动标注结果直接作为标注,人工仅审核确认;封面(上传自动转 jpg + UUID 命名)/**描述**/AI 生成图片(provider 抽象:dashscope 通义万相付费 API / localai 训练机 local-ai qwen-image,`config.yml imageGen` 节点切换,见配置说明);AI 标注端点与训练机 SSH 为**全局配置,直接读 `config.yml`**(`localAi` / `training.ssh` 节点,改配置需重启服务);图片落服务器 `app.datasetDir`/`datasets/<数据集名>/`,DB 存元数据 + 标注 JSON | +| 数据训练(唯一入口) | 后台管理端「数据训练」一个菜单承载数据集全流程:**数据集卡片列表**(封面图/描述/图片数/已标注数/**训练状态徽标**),**卡片下方直接展示训练任务进度条与状态**(无独立训练页);详情页为**图片与标注一体视图**:分页(每页 20 条)逐行「原图 ‖ 标注图」对照展示;**图片入库(手动上传/AI 生成)自动触发 RF-DETR 全图扫描标注**,进度条展示在页顶;页顶另有「全量标注」按钮可手动重标全部图片(覆盖各图已有标注);点击原图/标注图弹窗放大,弹窗为**审核视图(不做手动画框)**:点击框选中,列表可确认疑似框/删除误检框/清空并保存——AI 自动标注结果直接作为标注,人工仅审核确认;封面(上传/生成统一 1248x704 转 jpg + UUID 命名)/**描述**/AI 生成图片(provider 抽象:dashscope 通义万相付费 API / localai 训练机 local-ai qwen-image,`config.yml imageGen` 节点切换,见配置说明);AI 标注端点与训练机 SSH 为**全局配置,直接读 `config.yml`**(`localAi` / `training.ssh` 节点,改配置需重启服务);图片落服务器 `app.datasetDir`/`datasets/<数据集名>/`,DB 存元数据 + 标注 JSON | | 模型训练 | 从数据集卡片「开始训练」一键触发(参数 imgsz/epochs/batch/device 默认走 `config.yml` `training` 节点,部署级配置):进度/日志/指标监控(每 epoch 粒度)、取消;训练通道 `training` 节点可配置 subprocess(与 Go 服务同机直接起 python)/ ssh(异机执行,SSH 凭据取 `config.yml` `training.ssh` 节点),并发度 1(GPU 独占);训练脚本 `server/training/train_server.py`(随项目迁移,2026-08-26)参数化,产物(best.tflite/best.pt/曲线)拉回服务器;训练收尾自动做 **tflite 产物自检**(输入/输出 shape 校验,原 `inspect_tflite.py` 逻辑内嵌脚本),自检失败任务置失败并带出原因;`dump_graph.py` 留作训练机人工深度调试 | | 模型版本与热更新 | **每数据集一个模型**:训练完成后一键「发布」(训练任务操作列)——tflite 落 `workspace/trainings/<文件名前缀>.tflite`(前缀空回退数据集名) + sha256/指标/类别名入 `model_version`(按数据集独立版本序列 m1.0.0 递增)。管理端**无模型管理界面**(版本记录仅支撑客户端下发)。**App 模型热更新**:`GET /api/v1/app/update` 扩展返回 `models` 目录数组,客户端独立检查,新模型下载校验替换,失败回退旧模型——模型迭代不再重打包 APK | | 模型目录与多模型推理 | `GET /api/v1/models`(登录态)返回全部数据集当前生效模型(数据集/版本/类别/大小/sha256/下载地址),下载 URL `/download/trainings/<文件名前缀>.tflite`(前缀空回退数据集名);**App 模型管理页**用户自由下载/删除/启用模型,识别时**加载全部已启用模型并行推理 + 跨模型 NMS 合并**(按类别名),内置 assets 模型兜底 | @@ -179,13 +179,13 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow | GET | `/admin/app-versions` | 版本记录列表,`page/size` 分页,按下发时间倒序 | | POST | `/admin/app-versions` | 下发新版本(multipart/form-data):`notes` + `file`(APK 文件,仅接受 `.apk`);**版本号从文件名识别**,文件须命名为 `observer-x.y.z.apk`(如 `observer-1.0.1.apk`),格式不符拒绝;版本号不可重复,APK 上传覆盖 `app.apkDir`/`observer-latest.apk`(目录永远只有一个文件);检测到新版本即强制更新 | | POST | `/admin/app-versions/delete` | 删除版本记录 `{"id":1}`:删**最新版本**时联动删除 APK 文件(客户端不再提示更新、下载 404);删历史版本只删记录不动文件 | -| POST | `/admin/datasets` | 创建数据集 `{"name":"pheasant_v2","namePrefix":"pheasant","source":"manual"\|"ai","cover":"<文件名>"}`(name ≤50 字唯一,目录自动建;namePrefix=AI 生成图文件名前缀,生成图按 `<前缀>_<两位序号>.jpg` 顺序命名;物种=数据集名(单物种规则),创建时同步调 VLM(qwen3.6-35b-a3b) 自动生成物种/场景/动作/遮挡/站高/类别名等生成参数池,响应含 `poolsGenerated`/`poolError`——VLM 失败不阻断创建,参数可事后用 gen-pools 补生成;封面优先用 cover 参数(新建对话框预生成封面回传,跳过自动生成),否则参数池成功后自动生成 16:9(1024x576)封面(1 雄 1 雌并排,响应含 `coverGenerated`/`coverError`,失败可在编辑模式重新生成);模型生成图统一转 jpg 落盘) | +| POST | `/admin/datasets` | 创建数据集 `{"name":"pheasant_v2","namePrefix":"pheasant","source":"manual"\|"ai","cover":"<文件名>"}`(name ≤50 字唯一,目录自动建;namePrefix=AI 生成图文件名前缀,生成图按 `<前缀>_<两位序号>.jpg` 顺序命名;物种=数据集名(单物种规则),创建时同步调 VLM(qwen3.6-35b-a3b) 自动生成物种/场景/动作/遮挡/站高/类别名等生成参数池,响应含 `poolsGenerated`/`poolError`——VLM 失败不阻断创建,参数可事后用 gen-pools 补生成;封面优先用 cover 参数(新建对话框预生成封面回传,跳过自动生成),否则参数池成功后自动生成 16:9(1248x704)封面(1 雄 1 雌并排,响应含 `coverGenerated`/`coverError`,失败可在编辑模式重新生成);模型生成图统一转 jpg 落盘) | | GET | `/admin/datasets` | 数据集列表:`page/size` 分页,返回 `{total, list}`(含 imageCount/labeledCount/status/cover/description/**training 聚合状态**:最新训练记录的 status/currentEpoch/totalEpochs) | | POST | `/admin/datasets/update` | 更新数据集配置 `{"id":1,"name":"新名","namePrefix":"pheasant","description":"...","cover":"a.jpg"}`:名称(改名)/文件名前缀/描述/封面,空值字段不覆盖原值;gen_* 生成参数池不在此维护(仅 VLM 生成,见 gen-pools);改名同步迁移图片目录与模型文件,标注/训练进行中拒绝 | | POST | `/admin/datasets/gen-pools` | 重新生成数据集生成参数池 `{"datasetId":1}`:按数据集名(物种)调 VLM(qwen3.6-35b-a3b) 生成轮廓色/站高/场景/动作/遮挡并写表(覆盖旧值);失败报错保留旧值 | | POST | `/admin/datasets/images/vlm-review` | VLM 藏匿位补检 `{"datasetId":1,"imageId":5}`(两阶段标注第二阶段,**须与图像生成显存互斥**):qwen3.6-35b-a3b(+mmproj) 排除已确认框,按环境/光线/习性推理藏匿位,追加 ≤3 个疑似框(class 1)进同一 labels_json | -| POST | `/admin/datasets/cover` | 上传数据集封面(multipart:`datasetId`+`file`,jpg/jpeg/png ≤2MB):**自动转 jpg + UUID 命名**落盘并覆盖旧封面 | -| POST | `/admin/datasets/cover/generate` | 生成数据集封面 `{"datasetId":1}`(z-image 文生图:16:9、1 雄 1 雌,物种取 gen_species 空回退数据集名):覆盖旧封面,返回 `{cover}` 新文件名;`datasetId=0` 时传 `{"name":"家鸽"}` 新建预生成(数据集未创建,封面仅落盘不写库,创建请求带 cover 回传写库);与生成任务显存互斥,失败不覆盖旧封面 | +| POST | `/admin/datasets/cover` | 上传数据集封面(multipart:`datasetId`+`file`,jpg/jpeg/png ≤10MB):**自动缩放 1248x704 + 转 jpg + UUID 命名**落盘并覆盖旧封面 | +| POST | `/admin/datasets/cover/generate` | 生成数据集封面 `{"datasetId":1}`(z-image 文生图:16:9(1248x704)、1 雄 1 雌,物种取 gen_species 空回退数据集名):覆盖旧封面,返回 `{cover}` 新文件名;`datasetId=0` 时传 `{"name":"家鸽"}` 新建预生成(数据集未创建,封面仅落盘不写库,创建请求带 cover 回传写库);与生成任务显存互斥,失败不覆盖旧封面 | | GET | `/admin/datasets/cover` | 封面文件(静态字节流,`datasetId` 定位;`datasetId=0` 时按 `name`+`filename` 直读——新建对话框预生成封面回显) | | POST | `/admin/datasets/cover/delete` | 删除数据集封面 `{"datasetId":1}`:删文件 + 清 cover 字段 | | POST | `/admin/datasets/upload` | 上传图片(multipart/form-data:`datasetId` + `files` 多张,仅接受 `.jpg/.jpeg/.png`),存 `app.datasetDir`/`datasets//`,逐张入库 | diff --git a/server/biz/service/cover_resize_test.go b/server/biz/service/cover_resize_test.go new file mode 100644 index 0000000..709556c --- /dev/null +++ b/server/biz/service/cover_resize_test.go @@ -0,0 +1,69 @@ +package service + +import ( + "bytes" + "image" + "image/color" + "image/jpeg" + "image/png" + "testing" +) + +// makeTestImage 生成指定尺寸的 jpg/png 测试图 +func makeTestImage(t *testing.T, w, h int, pngFmt bool) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, color.RGBA{R: uint8(x % 256), G: uint8(y % 256), B: 128, A: 255}) + } + } + var buf bytes.Buffer + if pngFmt { + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + } else { + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 92}); err != nil { + t.Fatal(err) + } + } + return buf.Bytes() +} + +// decodeSize 解码并返回宽高 +func decodeSize(t *testing.T, data []byte) (int, int) { + t.Helper() + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("解码失败: %v", err) + } + return img.Bounds().Dx(), img.Bounds().Dy() +} + +func TestResizeCover(t *testing.T) { + cases := []struct { + name string + w, h int + png bool + }{ + {"标准16:9大图", 3000, 1688, false}, + {"超宽图", 4000, 1000, false}, + {"超窄图", 800, 3000, false}, + {"接近目标尺寸", 1280, 720, false}, + {"小于目标尺寸", 800, 600, false}, + {"PNG透明通道", 2000, 1500, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + out, err := resizeCover(makeTestImage(t, c.w, c.h, c.png)) + if err != nil { + t.Fatalf("resizeCover 失败: %v", err) + } + gotW, gotH := decodeSize(t, out) + if gotW != coverWidth || gotH != coverHeight { + t.Fatalf("尺寸错误: got %dx%d, want %dx%d", gotW, gotH, coverWidth, coverHeight) + } + }) + } +} diff --git a/server/biz/service/dataset.go b/server/biz/service/dataset.go index bee5927..fa49501 100644 --- a/server/biz/service/dataset.go +++ b/server/biz/service/dataset.go @@ -7,6 +7,7 @@ import ( "fmt" "image" "image/jpeg" + "io" "math" "math/rand" "net/url" @@ -21,6 +22,7 @@ import ( "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/os/gtime" + "golang.org/x/image/draw" "observer-server/biz/consts" "observer-server/biz/dao" @@ -263,21 +265,22 @@ func (s *datasetService) AdminUploadCover(ctx context.Context, req *dto.AdminDat if !imgExts[ext] { return nil, gerror.New("封面仅支持 jpg/jpeg/png") } - if req.File.Size > 2*1024*1024 { - return nil, gerror.New("封面不能超过 2MB") + if req.File.Size > 10*1024*1024 { + return nil, gerror.New("封面不能超过 10MB") } raw, err := req.File.Open() if err != nil { return nil, gerror.Wrap(err, "读取封面文件失败") } defer func() { _ = raw.Close() }() - img, _, err := image.Decode(raw) + data, err := io.ReadAll(raw) if err != nil { - return nil, gerror.New("封面文件不是有效图片") + return nil, gerror.Wrap(err, "读取封面文件失败") } - var buf bytes.Buffer - if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 92}); err != nil { - return nil, gerror.Wrap(err, "封面转 jpg 失败") + // 统一压缩为 1248x704(任意尺寸源图 → 目标尺寸 jpg) + data, err = resizeCover(data) + if err != nil { + return nil, gerror.Wrap(err, "封面文件不是有效图片") } dir := common.DatasetImagesDir(ctx, dataset.Name) if err := os.MkdirAll(dir, 0o755); err != nil { @@ -287,7 +290,7 @@ func (s *datasetService) AdminUploadCover(ctx context.Context, req *dto.AdminDat _ = os.Remove(filepath.Join(dir, filepath.Base(dataset.Cover))) } name := common.UuidV4() + ".jpg" - if err := common.WriteFileAtomic(filepath.Join(dir, name), buf.Bytes()); err != nil { + if err := common.WriteFileAtomic(filepath.Join(dir, name), data); err != nil { return nil, gerror.Wrap(err, "保存封面失败") } if err := dao.Dataset.UpdateConfigs(ctx, dataset.Id, &entity.Dataset{Cover: name}); err != nil { @@ -435,6 +438,49 @@ func (s *datasetService) MigrateLegacyCovers(ctx context.Context) error { return nil } +// CompressExistingCovers 存量封面尺寸统一迁移:cover 非空且尺寸不是 1248x704 的 +// → resizeCover 压缩覆盖写(原子写)。幂等:已是目标尺寸的行跳过,新库空跑。 +func (s *datasetService) CompressExistingCovers(ctx context.Context) error { + list, err := dao.Dataset.ListAll(ctx) + if err != nil { + return err + } + compressed := 0 + for _, d := range list { + if d.Cover == "" || !isCoverName(d.Cover) { + continue + } + path := filepath.Join(common.DatasetImagesDir(ctx, d.Name), d.Cover) + data, err := os.ReadFile(path) + if err != nil { + continue + } + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + g.Log().Warningf(ctx, "数据集 %s 封面解码失败,跳过压缩: %v", d.Name, err) + continue + } + if img.Bounds().Dx() == coverWidth && img.Bounds().Dy() == coverHeight { + continue + } + out, err := resizeCover(data) + if err != nil { + g.Log().Errorf(ctx, "数据集 %s 封面压缩失败: %+v", d.Name, err) + continue + } + if err := common.WriteFileAtomic(path, out); err != nil { + g.Log().Errorf(ctx, "数据集 %s 封面压缩写回失败: %+v", d.Name, err) + continue + } + compressed++ + g.Log().Infof(ctx, "数据集 %s 封面压缩: %dx%d → %dx%d", d.Name, img.Bounds().Dx(), img.Bounds().Dy(), coverWidth, coverHeight) + } + if compressed > 0 { + g.Log().Infof(ctx, "封面存量压缩完成: 共 %d 个数据集", compressed) + } + return nil +} + // AdminCreateDataset 新建数据集:名称唯一(UNIQUE 兜底)+ 创建图片目录 + // VLM 同步生成生成参数池(失败不阻断创建,poolsGenerated/poolError 反馈)。 func (s *datasetService) AdminCreateDataset(ctx context.Context, req *dto.AdminDatasetCreateReq) (*dto.AdminDatasetCreateRes, error) { @@ -527,7 +573,50 @@ func ensureJpeg(data []byte) ([]byte, error) { return buf.Bytes(), nil } -// genCoverWithImageGen 用 imageGen provider 生成数据集封面:16:9(1024x576)、画面 1 雄 1 雌, +// 封面统一尺寸(2026-08-31 用户定案):生成/上传/存量压缩一律 1248x704(16:9 横幅,App 卡片展示规格) +const ( + coverWidth = 1248 + coverHeight = 704 +) + +// resizeCover 封面图片统一压缩为 1248x704:解码校验 → 按目标比例中心裁剪 → CatmullRom 缩放 → jpg(Quality 92)。 +// 透明通道(png)先合成白底再缩放(jpg 无 alpha,白底比黑底自然)。 +func resizeCover(data []byte) ([]byte, error) { + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, gerror.Wrap(err, "封面图片解码失败") + } + src := image.NewRGBA(img.Bounds()) + draw.Draw(src, src.Bounds(), image.White, image.Point{}, draw.Src) + draw.Draw(src, src.Bounds(), img, image.Point{}, draw.Over) + // 中心裁剪到 1248:704 比例(源图过宽裁左右,过窄裁上下) + sw, sh := src.Bounds().Dx(), src.Bounds().Dy() + targetRatio := float64(coverWidth) / float64(coverHeight) + if ratio := float64(sw) / float64(sh); ratio > targetRatio { + cw := int(float64(sh) * targetRatio) + src = cropCenter(src, cw, sh) + } else if ratio < targetRatio { + ch := int(float64(sw) / targetRatio) + src = cropCenter(src, sw, ch) + } + dst := image.NewRGBA(image.Rect(0, 0, coverWidth, coverHeight)) + draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) + var buf bytes.Buffer + if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 92}); err != nil { + return nil, gerror.Wrap(err, "封面转 jpg 失败") + } + return buf.Bytes(), nil +} + +// cropCenter 取源图中心 w×h 区域(src 为 RGBA,SubImage 返回同类型) +func cropCenter(src *image.RGBA, w, h int) *image.RGBA { + b := src.Bounds() + x0 := b.Min.X + (b.Dx()-w)/2 + y0 := b.Min.Y + (b.Dy()-h)/2 + return src.SubImage(image.Rect(x0, y0, x0+w, y0+h)).(*image.RGBA) +} + +// genCoverWithImageGen 用 imageGen provider 生成数据集封面:16:9(1248x704)、画面 1 雄 1 雌, // 同一物种雄性+雌性各一只;UUID 命名落盘,返回新文件名。 // datasetId>0:删旧封面文件 + 更新库 cover 字段(与手动上传同路径); // datasetId<=0:仅落盘(新建对话框预生成,数据集未创建,创建请求带 cover 回传写库)。 @@ -552,11 +641,12 @@ func (s *datasetService) genCoverWithImageGen(ctx context.Context, datasetId int "雄雌各一只清晰可见,16:9 横幅构图,真实照片质感,光线自然,画面清晰美观", species, species) genCtx, cancel := context.WithTimeout(ctx, 120*time.Second) defer cancel() - data, err := provider.Generate(genCtx, prompt, "1024x576") + // 直接指定封面统一尺寸 1248x704(z-image 接受 16 整除尺寸);resizeCover 兜底统一规格 + data, err := provider.Generate(genCtx, prompt, "1248x704") if err != nil { return "", gerror.Wrap(err, "封面生成失败") } - if data, err = ensureJpeg(data); err != nil { + if data, err = resizeCover(data); err != nil { return "", err } dir := common.DatasetImagesDir(ctx, datasetName) diff --git a/server/biz/service/workspace/observer-latest.apk b/server/biz/service/workspace/observer-latest.apk new file mode 100644 index 0000000..dbb5e2a --- /dev/null +++ b/server/biz/service/workspace/observer-latest.apk @@ -0,0 +1 @@ +apk \ No newline at end of file diff --git a/server/go.mod b/server/go.mod index 91891d1..4ccb5ae 100644 --- a/server/go.mod +++ b/server/go.mod @@ -8,6 +8,7 @@ require ( github.com/smartwalle/alipay/v3 v3.2.31 github.com/wechatpay-apiv3/wechatpay-go v0.2.21 golang.org/x/crypto v0.55.0 + golang.org/x/image v0.45.0 ) require ( diff --git a/server/go.sum b/server/go.sum index 333a92b..3c7675f 100644 --- a/server/go.sum +++ b/server/go.sum @@ -98,6 +98,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= +golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/server/main.go b/server/main.go index 04bddc7..89604c2 100644 --- a/server/main.go +++ b/server/main.go @@ -20,6 +20,10 @@ func main() { if err := service.Dataset.MigrateLegacyCovers(ctx); err != nil { g.Log().Errorf(ctx, "封面存量迁移失败: %+v", err) } + // 封面存量尺寸统一:非 1248x704 的压缩覆盖写(幂等,新库空跑) + if err := service.Dataset.CompressExistingCovers(ctx); err != nil { + g.Log().Errorf(ctx, "封面存量压缩失败: %+v", err) + } s := g.Server() // Android APK 下载静态托管:app.apkDir 目录下固定文件 observer-latest.apk, diff --git a/server/技术设计.md b/server/技术设计.md index afbabbd..437a620 100644 --- a/server/技术设计.md +++ b/server/技术设计.md @@ -341,9 +341,10 @@ Android 客户端启动 GET /api/v1/app/update(公开,无需 token;iOS 不 - **单物种规则(2026-08-28 定案)**:每数据集只对应**一个物种**(每数据集训练一个模型,类别固定 `[物种, suspect]`);**生成表单无任何物种/动物名称输入,物种直接取数据集名**(生成提示词按数据集名组装);不同物种须拆到不同数据集(近缘种不共数据集) - **表结构(4 单值 + 3 JSON 数组,2026-08-28 简化)**:`gen_species`(单值 = 数据集物种,即标注类别 0 名)、`gen_tone`(单值 轮廓色词,白化个体配浅色)、`gen_heights`(REAL 数值 站高 cm)、`gen_scenes`/`gen_actions`/`gen_occlusions`(JSON 数组,各 ≥3 条,保持多样性)、`gen_classes`(单值 第二标注类别名 = "suspect",第一类别 = gen_species,写入训练 data.yaml 的 names);**简化动机**:单物种规则下原 JSON 对象/数组恒为 1 key 1 value(`[物种]`/`{物种:值}`),两性差异由提示词性别随机词表达,站高单值对距离感公式(像素高 = 站高 × focalPx ÷ 距离)影响 <1 像素量级,无需分键 - **创建数据集同步生成**:`POST /datasets` 不接收任何物种/动物名称参数,**物种 = 数据集名**(单物种规则,新建对话框无动物名称输入框);调 `QwenVL`(64x64 占位图 + 文本,llama.cpp mmproj 需图片输入)输出严格 JSON `{tone, height_cm, scenes[], actions[], occlusions[]}`,校验(scenes/actions/occlusions ≥3 条、height_cm 数值 10~200、tone 为颜色词)后组装写表:`gen_species=物种`、`gen_tone=tone`、`gen_heights=height_cm`、`gen_classes="suspect"`;**站高属数据库数据(gen_heights),代码不硬编码物种→高度**——VLM 输出即最终值,界面不维护(2026-08-28 用户定案:gen_* 7 列全部仅由模型生成,编辑界面只读展示,值不准走 `gen-pools` 重新生成);**失败不阻断创建**——返回 `poolsGenerated:false` + `poolError`,前端提示(可事后 `POST /datasets/gen-pools` 补生成);显存互斥:存在任意 running 生成任务(z-image 占显存)时跳过生成并提示;前端创建请求超时放宽 180s -- **封面自动生成(2026-08-28)**:参数池生成成功后取数据集物种调 z-image 文生图,**16:9 横幅(1024x576)**,提示词要求**「一只雄性 X 和一只雌性 X 并排站立」**(1 雄 1 雌),失败不阻断创建(返回 `coverGenerated:false` + `coverError`,可编辑模式重新生成);结果 `ensureJpeg` 转 jpg 后按封面规范落盘(UUIDv4.jpg,删除旧封面,写 `cover` 列);与生成任务显存互斥(有 running 生成任务时跳过) +- **封面自动生成(2026-08-28)**:参数池生成成功后取数据集物种调 z-image 文生图,**16:9 横幅(1248x704,2026-08-31 由 1024x576 调整)**,提示词要求**「一只雄性 X 和一只雌性 X 并排站立」**(1 雄 1 雌),失败不阻断创建(返回 `coverGenerated:false` + `coverError`,可编辑模式重新生成);结果 `resizeCover` 转 jpg(统一 1248x704 兜底)后按封面规范落盘(UUIDv4.jpg,删除旧封面,写 `cover` 列);与生成任务显存互斥(有 running 生成任务时跳过) - **封面手动生成(2026-08-28)**:新增 `POST /datasets/cover/generate`(对话框「AI 生成封面」按钮)——**新建模式** `datasetId=0` + `name` 预生成:数据集未创建,封面仅落盘于数据集目录(物种=表单数据集名),创建请求带 `cover` 回传写库(创建接口 cover 非空且文件存在则跳过自动生成);**编辑模式** `datasetId` 完整链路(物种取 gen_species 空回退数据集名,写库+删旧封面);返回 `{cover}` 供前端回显;封面访问接口支持 `datasetId=0` 按 `name`+`filename` 直读(filename 须 UUID jpg 规范防路径穿越) - **生成图统一转 jpg(2026-08-28)**:凡模型产出的图片(z-image 训练图、封面)入库前经 `ensureJpeg` 解码校验,非 jpeg 一律重编码 jpg(Quality 92)——RF-DETR 按扩展名推断 mime,扩展名与编码不一致会报「Not a JPEG file」类错误;命名统一 `.jpg` +- **封面统一尺寸(2026-08-31,用户定案)**:封面一律 **1248x704**(16:9 横幅,App 列表卡片展示规格)——AI 生成直接向 provider 指定该尺寸(z-image 接受 16 整除尺寸);手动上传任意尺寸经 `resizeCover` 中心裁剪缩放统一(上传限制放宽 2MB→10MB);存量封面启动时 `CompressExistingCovers` 幂等压缩覆盖写(非 1248x704 才重压,目标尺寸跳过) - **重新生成接口**:`POST /datasets/gen-pools`(`{datasetId}`,物种取数据集名)——编辑对话框「VLM 重新生成参数」按钮入口,VLM 失败报错(不覆盖旧值,旧值保留) - **读取链(无 config 兜底)**:物种 = 表 `gen_species` 单值 → 空则数据集名本身(**固定,不随机**);场景/动作/遮挡 = 表池随机 → **空则模板组装报错**(提示手填提示词或编辑数据集补参数);`{tone}` = 表 `gen_tone` 单值 → 空默认「深色」;站高 = 表 `gen_heights` 数值(**创建时 VLM 生成,界面不维护,代码/配置不硬编码**)→ ≤0 兜底 35cm(物理默认,非配置回退);标注类别名 = 第一类 `gen_species` + 第二类 `gen_classes`(训练 data.yaml / 模型 labels) - **界面不维护 gen_*(2026-08-28 用户定案)**:`gen_species/gen_tone/gen_heights/gen_scenes/gen_actions/gen_occlusions/gen_classes` 全部仅由模型生成(创建数据集时 VLM 自动、`gen-pools` 重新生成),管理端编辑表单对 7 列只读展示、无输入框——`POST /datasets/update` 不接收任何 gen 字段;config.yml 无任何 per-dataset 生成参数节点(`speciesByDataset/speciesTone/speciesHeights/sceneByDataset/actionByDataset/occlusionByDataset/scenes/actions/occlusions/localAi.classNames` 已删,代码无引用),保留:`promptTemplate`(通用模板)与 `lights`(光线池)、`assumedVfovDeg`;数据源唯一入口为数据集表(本库存量 15 行已直接改单值 + 真实站高,无启动迁移);**雪地场景补齐(2026-08-31,按冬季习性)**:VLM 生成场景池缺冬季场景(全部为夏秋农田),13 数据集 `gen_scenes` 直接 SQL 追加 2~3 条雪地条目(农田类雉鸡/鸽子、兔子荒坡、白马鸡蓝马鸡高山林缘);**鸭子/鹌鹑不加**——北方冬季南迁(水面封冻、候鸟不越冬),雪地场景与习性矛盾(用户拍板);`genPoolsWithVLM` 提示词已加「按冬季习性配场景:本地活跃物种须含积雪场景,南迁物种不配雪地」防新数据集一刀切;**林缘/树栖场景补齐(2026-08-31,按栖息习性)**:雉类+鸽子共享场景池(10 数据集)缺林地交接处与树上落栖场景,SQL 追加 5 条(2 林缘 + 3 树上)+ 2 条树上动作(枝头停栖/树枝上歇息);**石鸡剔除树上条目(用户定案)**——岩石地栖不上树,3 树上场景 + 2 树上动作从石鸡池移除(保留 2 林缘);**总原则(用户定案):场景与遮挡物必须符合对应动物习性**——场景(雪地/林缘/树上/水面)与遮挡物(草丛/树叶/树枝/芦苇)均按物种习性逐条判断,习性与场景矛盾即剔除;**遮挡规则(适用于所有具备树上场景的动物)**:遮挡不一定是草丛,树栖/林缘物种遮挡池须含树叶/树枝类遮挡(雉类池原已含 2 条;白马鸡/蓝马鸡补 2 条;兔子原已有 1 条林缘树叶);`genPoolsWithVLM` 提示词同步加「树栖物种场景须含林缘与树上落栖」「具备树上/林缘场景的物种遮挡池须含树叶/树枝类遮挡」