This commit is contained in:
2026-08-31 18:35:58 +08:00
parent 36dc258224
commit dbe31b94a3
18 changed files with 822 additions and 58 deletions
+11
View File
@@ -3,6 +3,17 @@
动物实时识别 AppFlutter 版)。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
### 构建与安装
+38
View File
@@ -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"
+52 -40
View File
@@ -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<CameraScreen> {
void _openSettings() {
final vm = _viewModel;
if (vm == null) return;
ModelManager.instance.refresh();
showModalBottomSheet<void>(
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),
],
),
),
),
),
@@ -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,
],
),
);
}
}
+36
View File
@@ -81,6 +81,7 @@ class ModelManager extends ChangeNotifier {
final Set<int> _downloadedIds = {};
final Map<int, double> _progress = {};
final Map<int, String> _errors = {};
final Set<int> _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();
+1 -1
View File
@@ -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
@@ -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<int> bytes) => sha256.convert(bytes).toString();
/// 分两段到达的下载流(中间 50ms 停顿,供取消测试在下载中触发)
Stream<List<int>> _delayedChunks() async* {
yield [1, 2, 3, 4];
await Future<void>.delayed(const Duration(milliseconds: 50));
yield [5, 6, 7, 8];
}
Map<String, dynamic> _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<void> 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);
});
}
+39
View File
@@ -11,6 +11,13 @@ const _modelBytes = [1, 2, 3, 4, 5, 6, 7, 8];
String _shaHex(List<int> bytes) => sha256.convert(bytes).toString();
/// 分两段到达的下载流(中间 50ms 停顿,供取消测试在下载中触发)
Stream<List<int>> _delayedChunks() async* {
yield [1, 2, 3, 4];
await Future<void>.delayed(const Duration(milliseconds: 50));
yield [5, 6, 7, 8];
}
Map<String, dynamic> _catalog(List<Map<String, dynamic>> 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<void>.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 残留应被清理');
});
}
+83
View File
@@ -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<int> bytes) => sha256.convert(bytes).toString();
Map<String, dynamic> _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');
});
}
+3 -1
View File
@@ -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 .
+4 -4
View File
@@ -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:91248x704、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/<name>/`,逐张入库 |
+69
View File
@@ -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)
}
})
}
}
+101 -11
View File
@@ -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:91024x576)、画面 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 为 RGBASubImage 返回同类型)
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:91248x704)、画面 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)
@@ -0,0 +1 @@
apk
+1
View File
@@ -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 (
+2
View File
@@ -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=
+4
View File
@@ -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
+2 -1
View File
@@ -341,9 +341,10 @@ Android 客户端启动 GET /api/v1/app/update(公开,无需 tokeniOS 不
- **单物种规则(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 横幅(1248x7042026-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 规范防路径穿越)
- **生成图统一转 jpg2026-08-28**:凡模型产出的图片(z-image 训练图、封面)入库前经 `ensureJpeg` 解码校验,非 jpeg 一律重编码 jpgQuality 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` 提示词同步加「树栖物种场景须含林缘与树上落栖」「具备树上/林缘场景的物种遮挡池须含树叶/树枝类遮挡」