diff --git a/flutter_app/lib/annotate/annotate_api.dart b/flutter_app/lib/annotate/annotate_api.dart new file mode 100644 index 0000000..3bc151a --- /dev/null +++ b/flutter_app/lib/annotate/annotate_api.dart @@ -0,0 +1,260 @@ +import 'dart:convert'; +import 'dart:io' show Platform; + +import 'package:cupertino_http/cupertino_http.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:http/http.dart' as http; + +import '../auth/session_store.dart'; +import '../config/app_config.dart'; + +/// 标注众包 API 客户端(契约见 server/README.md「标注众包」)。 +/// 异常语义同 OrderApi:失败抛 AnnotateApiException,登录失效抛 SessionExpired。 +class AnnotateApiException implements Exception { + final String message; + const AnnotateApiException(this.message); + + @override + String toString() => message; +} + +class SessionExpiredException extends AnnotateApiException { + const SessionExpiredException() : super('登录已失效,请重新登录'); +} + +/// 众包任务条目 +class AnnotateTaskInfo { + final int id; + final String name; + final String datasetName; + final String species; + final int poolRemain; + + const AnnotateTaskInfo({ + required this.id, + required this.name, + required this.datasetName, + required this.species, + required this.poolRemain, + }); + + factory AnnotateTaskInfo.fromJson(Map j) => AnnotateTaskInfo( + id: (j['id'] as num).toInt(), + name: (j['name'] as String?) ?? '', + datasetName: (j['datasetName'] as String?) ?? '', + species: (j['species'] as String?) ?? '', + poolRemain: (j['poolRemain'] as num?)?.toInt() ?? 0, + ); +} + +/// 我的标注统计(进度/奖励/冻结) +class AnnotateStats { + final int submittedTotal; + final int approved; + final int rejected; + final double approveRatio; + final int progressDone; + final int rewardPerImages; + final int rewardMinutes; + final int totalEarnedMinutes; + final int todayEarnedMinutes; + final int dailyCapMinutes; + final DateTime? frozenUntil; + final DateTime? expiresAt; + + const AnnotateStats({ + required this.submittedTotal, + required this.approved, + required this.rejected, + required this.approveRatio, + required this.progressDone, + required this.rewardPerImages, + required this.rewardMinutes, + required this.totalEarnedMinutes, + required this.todayEarnedMinutes, + required this.dailyCapMinutes, + required this.frozenUntil, + required this.expiresAt, + }); + + bool get frozen => frozenUntil != null && frozenUntil!.isAfter(DateTime.now()); + + factory AnnotateStats.fromJson(Map j) => AnnotateStats( + submittedTotal: (j['submittedTotal'] as num?)?.toInt() ?? 0, + approved: (j['approved'] as num?)?.toInt() ?? 0, + rejected: (j['rejected'] as num?)?.toInt() ?? 0, + approveRatio: (j['approveRatio'] as num?)?.toDouble() ?? 1, + progressDone: (j['progressDone'] as num?)?.toInt() ?? 0, + rewardPerImages: (j['rewardPerImages'] as num?)?.toInt() ?? 10, + rewardMinutes: (j['rewardMinutes'] as num?)?.toInt() ?? 30, + totalEarnedMinutes: (j['totalEarnedMinutes'] as num?)?.toInt() ?? 0, + todayEarnedMinutes: (j['todayEarnedMinutes'] as num?)?.toInt() ?? 0, + dailyCapMinutes: (j['dailyCapMinutes'] as num?)?.toInt() ?? 120, + frozenUntil: j['frozenUntil'] == null + ? null + : DateTime.tryParse(j['frozenUntil'] as String), + expiresAt: j['expiresAt'] == null + ? null + : DateTime.tryParse(j['expiresAt'] as String), + ); +} + +/// 领取到的单张图 +class AnnotateClaimImage { + final int imageId; + final String url; // 相对路径,需拼 baseUrl;访问需 Bearer 头 + final int width; + final int height; + + const AnnotateClaimImage({ + required this.imageId, + required this.url, + required this.width, + required this.height, + }); + + factory AnnotateClaimImage.fromJson(Map j) => + AnnotateClaimImage( + imageId: (j['imageId'] as num).toInt(), + url: (j['url'] as String?) ?? '', + width: (j['width'] as num?)?.toInt() ?? 0, + height: (j['height'] as num?)?.toInt() ?? 0, + ); +} + +/// 提交结果(是否触发奖励发放) +class AnnotateSubmitResult { + final bool granted; + final int minutes; + final int todayEarnedMinutes; + final int progressDone; + + const AnnotateSubmitResult({ + required this.granted, + required this.minutes, + required this.todayEarnedMinutes, + required this.progressDone, + }); + + factory AnnotateSubmitResult.fromJson(Map j) => + AnnotateSubmitResult( + granted: j['granted'] == true, + minutes: (j['minutes'] as num?)?.toInt() ?? 0, + todayEarnedMinutes: (j['todayEarnedMinutes'] as num?)?.toInt() ?? 0, + progressDone: (j['progressDone'] as num?)?.toInt() ?? 0, + ); +} + +class AnnotateApi { + final String baseUrl; + final SessionStore sessionStore; + final http.Client _client; + + // iOS 本地网络 socket 拦截绕行,同 OrderApi(见其注释) + AnnotateApi({String? baseUrl, required this.sessionStore, http.Client? client}) + : baseUrl = baseUrl ?? AppConfig.apiBaseUrl, + _client = client ?? _defaultHttpClient(); + + static http.Client _defaultHttpClient() { + if (!kIsWeb && Platform.isIOS) { + return CupertinoClient.defaultSessionConfiguration(); + } + return http.Client(); + } + + /// 可领任务列表 + 我的统计 + Future<(List, AnnotateStats)> tasks() async { + final json = await _get('/api/v1/annotate/tasks'); + final list = (json['list'] as List? ?? []) + .map((e) => AnnotateTaskInfo.fromJson((e as Map).cast())) + .toList(); + final stats = AnnotateStats.fromJson( + ((json['stats'] as Map?) ?? const {}).cast()); + return (list, stats); + } + + /// 领取一批标注图片 + Future> claim(int taskId) async { + final json = await _post( + '/api/v1/annotate/claim', jsonEncode({'taskId': taskId})); + return (json['images'] as List? ?? []) + .map((e) => + AnnotateClaimImage.fromJson((e as Map).cast())) + .toList(); + } + + /// 提交单张标注(boxes 元素:{class,cx,cy,w,h,confidence},归一化坐标) + Future submit( + int imageId, List> boxes) async { + final json = await _post( + '/api/v1/annotate/submit', jsonEncode({'imageId': imageId, 'boxes': boxes})); + return AnnotateSubmitResult.fromJson(json); + } + + /// 我的统计 + Future me() async { + final json = await _get('/api/v1/annotate/me'); + return AnnotateStats.fromJson( + ((json['stats'] as Map?) ?? const {}).cast()); + } + + /// 图片完整 URL(Image.network 用,需带 Bearer 头) + Uri imageUrl(String relative) => Uri.parse('$baseUrl$relative'); + + /// 图片请求头(标注器 Image.network 加载用) + Future> imageHeaders() => _authHeaders(); + + Future> _authHeaders() async { + final token = await sessionStore.readToken(); + if (token == null || token.isEmpty) { + throw const SessionExpiredException(); + } + return {'Authorization': 'Bearer $token'}; + } + + Future> _get(String path) async { + try { + final res = await _client + .get(Uri.parse('$baseUrl$path'), headers: await _authHeaders()) + .timeout(const Duration(seconds: 30)); + return _decode(res); + } catch (e) { + if (e is AnnotateApiException) rethrow; + throw AnnotateApiException('网络请求失败: $e'); + } + } + + Future> _post(String path, String body) async { + try { + final res = await _client + .post(Uri.parse('$baseUrl$path'), + headers: { + 'Content-Type': 'application/json', + ...await _authHeaders(), + }, + body: body) + .timeout(const Duration(seconds: 30)); + return _decode(res); + } catch (e) { + if (e is AnnotateApiException) rethrow; + throw AnnotateApiException('网络请求失败: $e'); + } + } + + Map _decode(http.Response res) { + final Map json; + try { + json = jsonDecode(res.body) as Map; + } catch (_) { + throw AnnotateApiException('服务端响应异常 (${res.statusCode})'); + } + if (res.statusCode != 200 || json['code'] != 0) { + final message = json['message'] as String? ?? '服务端错误 (${res.statusCode})'; + if (json['code'] == 61) { + throw const SessionExpiredException(); + } + throw AnnotateApiException(message); + } + return json['data'] as Map; + } +} diff --git a/flutter_app/lib/annotate/annotate_tasks_screen.dart b/flutter_app/lib/annotate/annotate_tasks_screen.dart new file mode 100644 index 0000000..c836735 --- /dev/null +++ b/flutter_app/lib/annotate/annotate_tasks_screen.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; + +import 'annotate_api.dart'; +import 'annotator_screen.dart'; + +String _fmt(DateTime t) { + String p(int n) => n.toString().padLeft(2, '0'); + return '${t.month}/${t.day} ${p(t.hour)}:${p(t.minute)}'; +} + +/// 标注赚时长首页:我的统计(进度/今日已得/冻结态)+ 可领任务列表。 +/// 领取后进入标注器逐张画框提交,提交满 [AnnotateStats.rewardPerImages] 张自动到账时长。 +class AnnotateTasksScreen extends StatefulWidget { + final AnnotateApi api; + + const AnnotateTasksScreen({super.key, required this.api}); + + @override + State createState() => _AnnotateTasksScreenState(); +} + +class _AnnotateTasksScreenState extends State { + late Future<(List, AnnotateStats)> _future; + bool _claiming = false; + + @override + void initState() { + super.initState(); + _future = widget.api.tasks(); + } + + void _reload() { + setState(() => _future = widget.api.tasks()); + } + + Future _claim(AnnotateTaskInfo task) async { + if (_claiming) return; + setState(() => _claiming = true); + try { + final images = await widget.api.claim(task.id); + if (!mounted) return; + if (images.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('任务池暂时没有可领取的图片,稍后再来'))); + _reload(); + return; + } + final earned = await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => AnnotatorScreen(api: widget.api, task: task, images: images), + )); + if (earned == true) _reload(); // 本批有提交:回列表刷新进度/奖励 + } on SessionExpiredException { + if (mounted) _sessionExpired(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'.replaceFirst('Exception: ', '')))); + } + } finally { + if (mounted) setState(() => _claiming = false); + } + } + + void _sessionExpired() { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('登录已失效,请重新登录'))); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('标注赚时长')), + body: FutureBuilder<(List, AnnotateStats)>( + future: _future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('加载失败:${snap.error}'.replaceFirst('Exception: ', '')), + const SizedBox(height: 12), + FilledButton(onPressed: _reload, child: const Text('重试')), + ], + ), + ); + } + final (tasks, stats) = snap.data!; + return RefreshIndicator( + onRefresh: () async => _reload(), + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + children: [ + _StatsCard(stats: stats), + const SizedBox(height: 16), + Text('可领任务', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + if (tasks.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(child: Text('暂无可领任务,敬请期待')), + ) + else + ...tasks.map((t) => Card( + margin: const EdgeInsets.only(bottom: 10), + child: ListTile( + title: Text(t.name, + style: const TextStyle(fontWeight: FontWeight.w600)), + subtitle: Text( + '物种:${t.species.isEmpty ? t.datasetName : t.species} · 池余量 ${t.poolRemain} 张'), + trailing: FilledButton( + onPressed: _claiming || stats.frozen || t.poolRemain <= 0 + ? null + : () => _claim(t), + child: const Text('领取'), + ), + ), + )), + ], + ), + ); + }, + ), + ); + } +} + +class _StatsCard extends StatelessWidget { + final AnnotateStats stats; + + const _StatsCard({required this.stats}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.emoji_events, + color: Colors.amber.shade700, size: 28), + const SizedBox(width: 8), + Text('标 ${stats.rewardPerImages} 张得 ${stats.rewardMinutes} 分钟', + style: theme.textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600)), + ], + ), + const SizedBox(height: 12), + LinearProgressIndicator( + value: stats.rewardPerImages <= 0 + ? 0 + : (stats.progressDone % stats.rewardPerImages) / + stats.rewardPerImages, + ), + const SizedBox(height: 6), + Text( + '本档进度 ${stats.progressDone}/${stats.rewardPerImages} · 今日已得 ${stats.todayEarnedMinutes}/${stats.dailyCapMinutes} 分钟 · 累计 ${stats.totalEarnedMinutes} 分钟', + style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey.shade600), + ), + Text( + '通过 ${stats.approved} / 拒绝 ${stats.rejected}(通过率 ${(stats.approveRatio * 100).toStringAsFixed(0)}%)', + style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey.shade600), + ), + if (stats.frozen) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Row(children: [ + const Icon(Icons.block, color: Colors.red, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + '标注质量未达标,资格冻结中(${_fmt(stats.frozenUntil!)} 解冻)', + style: const TextStyle(color: Colors.red, fontSize: 13), + ), + ), + ]), + ), + ], + ], + ), + ), + ); + } +} diff --git a/flutter_app/lib/annotate/annotator_screen.dart b/flutter_app/lib/annotate/annotator_screen.dart new file mode 100644 index 0000000..9e67aff --- /dev/null +++ b/flutter_app/lib/annotate/annotator_screen.dart @@ -0,0 +1,376 @@ +import 'package:flutter/material.dart'; + +import 'annotate_api.dart'; + +/// 标注器:逐张画框提交。交互:空白处拖拽画框、点框选中(高亮)、 +/// 撤销/删除选中;每张提交进「待审核」,满 rewardPerImages 张自动到账时长。 +class AnnotatorScreen extends StatefulWidget { + final AnnotateApi api; + final AnnotateTaskInfo task; + final List images; + + const AnnotatorScreen({ + super.key, + required this.api, + required this.task, + required this.images, + }); + + @override + State createState() => _AnnotatorScreenState(); +} + +/// 归一化框(cx,cy 中心 + w,h,0~1,同服务端 AdminLabelBox) +class _NormBox { + double cx; + double cy; + double w; + double h; + _NormBox(this.cx, this.cy, this.w, this.h); + + bool hit(Offset p) => + p.dx >= cx - w / 2 && p.dx <= cx + w / 2 && p.dy >= cy - h / 2 && p.dy <= cy + h / 2; +} + +class _AnnotatorScreenState extends State { + int _index = 0; + final List<_NormBox> _boxes = []; + int? _selected; + Offset? _dragStart; // 拖拽画框进行中(归一化坐标) + Offset? _dragCur; + bool _submitting = false; + bool _earnedThisBatch = false; + + AnnotateClaimImage get _current => widget.images[_index]; + + void _clamp(_NormBox b) { + // 边界钳制 + 防越界(归一化 0~1,同服务端校验) + b.cx = b.cx.clamp(0.0, 1.0); + b.cy = b.cy.clamp(0.0, 1.0); + b.w = b.w.clamp(0.001, 1.0); + b.h = b.h.clamp(0.001, 1.0); + } + + void _onPanStart(DragStartDetails d) { + _dragStart = d.localPosition; + _dragCur = d.localPosition; + setState(() {}); + } + + void _onPanUpdate(DragUpdateDetails d) { + _dragCur = d.localPosition; + setState(() {}); + } + + void _onPanEnd(DragEndDetails d) { + final s = _dragStart; + final e = _dragCur; + _dragStart = null; + _dragCur = null; + if (s == null || e == null) { + setState(() {}); + return; + } + final dx = (e.dx - s.dx).abs(); + final dy = (e.dy - s.dy).abs(); + if (dx < 8 || dy < 8) { + setState(() {}); // 过小视为误触 + return; + } + final box = _NormBox( + (s.dx + e.dx) / 2, + (s.dy + e.dy) / 2, + dx, + dy, + ); + final size = _canvasSize; + if (size == null || size.width <= 0 || size.height <= 0) { + setState(() {}); + return; + } + box.cx /= size.width; + box.cy /= size.height; + box.w /= size.width; + box.h /= size.height; + _clamp(box); + setState(() { + _boxes.add(box); + _selected = _boxes.length - 1; + }); + } + + Size? _canvasSize; + + void _onTapUp(TapUpDetails d) { + final p = d.localPosition; + final size = _canvasSize; + if (size == null) return; + final n = Offset(p.dx / size.width, p.dy / size.height); + int? hit; + for (var i = _boxes.length - 1; i >= 0; i--) { + if (_boxes[i].hit(n)) { + hit = i; + break; + } + } + setState(() => _selected = hit); + } + + void _deleteSelected() { + if (_selected == null) return; + setState(() { + _boxes.removeAt(_selected!); + _selected = null; + }); + } + + void _undo() { + if (_boxes.isEmpty) return; + setState(() { + _boxes.removeLast(); + _selected = null; + }); + } + + Future _submit({required bool noTarget}) async { + if (_submitting) return; + if (!noTarget && _boxes.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('请先画框,或使用「画面无目标」提交'))); + return; + } + setState(() => _submitting = true); + try { + final payload = noTarget + ? >[] + : _boxes + .map((b) => { + 'class': 0, + 'cx': b.cx, + 'cy': b.cy, + 'w': b.w, + 'h': b.h, + 'confidence': 1, + }) + .toList(); + final res = await widget.api.submit(_current.imageId, payload); + if (res.granted) _earnedThisBatch = true; + if (!mounted) return; + if (res.granted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text( + '恭喜!累计提交满档,${res.minutes} 分钟时长已到账(今日已得 ${res.todayEarnedMinutes} 分钟)'), + backgroundColor: Colors.green, + )); + } + _next(); + } on SessionExpiredException { + if (mounted) Navigator.of(context).pop(false); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('$e'.replaceFirst('Exception: ', '')))); + } + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + void _next() { + if (_index + 1 >= widget.images.length) { + // 本批完成:回任务列表刷新进度 + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(_earnedThisBatch ? '本批完成,奖励已到账' : '本批完成,继续加油!'))); + Navigator.of(context).pop(true); + return; + } + setState(() { + _index++; + _boxes.clear(); + _selected = null; + _dragStart = null; + _dragCur = null; + }); + } + + @override + Widget build(BuildContext context) { + final img = _current; + final aspect = + img.width > 0 && img.height > 0 ? img.width / img.height : 4 / 3; + return Scaffold( + appBar: AppBar( + title: Text('${widget.task.name}(${_index + 1}/${widget.images.length})'), + ), + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: FutureBuilder>( + future: widget.api.imageHeaders(), + builder: (context, snap) { + final headers = snap.data ?? const {}; + return LayoutBuilder(builder: (context, constraints) { + final maxW = constraints.maxWidth; + final maxH = constraints.maxHeight.isInfinite + ? MediaQuery.of(context).size.height * 0.55 + : constraints.maxHeight; + var w = maxW; + var h = w / aspect; + if (h > maxH) { + h = maxH; + w = h * aspect; + } + _canvasSize = Size(w, h); + return Center( + child: GestureDetector( + onPanStart: _onPanStart, + onPanUpdate: _onPanUpdate, + onPanEnd: _onPanEnd, + onTapUp: _onTapUp, + child: Stack( + children: [ + SizedBox( + width: w, + height: h, + child: Image.network( + widget.api.imageUrl(img.url).toString(), + headers: headers, + fit: BoxFit.fill, + loadingBuilder: (c, child, progress) => + progress == null + ? child + : const Center( + child: CircularProgressIndicator()), + errorBuilder: (c, e, st) => const Center( + child: Text('图片加载失败', + style: TextStyle(color: Colors.red))), + ), + ), + Positioned.fill( + child: CustomPaint( + painter: _BoxesPainter( + boxes: _boxes, + selected: _selected, + dragStart: _dragStart == null + ? null + : Offset(_dragStart!.dx / w, + _dragStart!.dy / h), + dragCur: _dragCur == null + ? null + : Offset( + _dragCur!.dx / w, _dragCur!.dy / h), + ), + ), + ), + ], + ), + ), + ); + }); + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton.icon( + onPressed: _boxes.isEmpty ? null : _undo, + icon: const Icon(Icons.undo), + label: const Text('撤销'), + ), + TextButton.icon( + onPressed: _selected == null ? null : _deleteSelected, + icon: const Icon(Icons.delete_outline), + label: const Text('删除选中'), + ), + Text( + '${_boxes.length} 框', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _submitting ? null : () => _submit(noTarget: true), + child: const Text('画面无目标'), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: FilledButton.icon( + onPressed: _submitting ? null : () => _submit(noTarget: false), + icon: _submitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.check), + label: Text(_submitting + ? '提交中…' + : '提交${_boxes.isEmpty ? '' : '(${_boxes.length} 框)'}'), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _BoxesPainter extends CustomPainter { + final List<_NormBox> boxes; + final int? selected; + final Offset? dragStart; // 归一化 + final Offset? dragCur; + + _BoxesPainter({ + required this.boxes, + required this.selected, + required this.dragStart, + required this.dragCur, + }); + + @override + void paint(Canvas canvas, Size size) { + final border = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + for (var i = 0; i < boxes.length; i++) { + final b = boxes[i]; + border.color = i == selected ? Colors.orange : Colors.green; + canvas.drawRect( + Rect.fromLTWH((b.cx - b.w / 2) * size.width, (b.cy - b.h / 2) * size.height, + b.w * size.width, b.h * size.height), + border, + ); + } + final s = dragStart; + final e = dragCur; + if (s != null && e != null) { + border.color = Colors.blue; + canvas.drawRect( + Rect.fromPoints(Offset(s.dx * size.width, s.dy * size.height), + Offset(e.dx * size.width, e.dy * size.height)), + border, + ); + } + } + + @override + bool shouldRepaint(covariant _BoxesPainter old) => + old.boxes != boxes || old.selected != selected || old.dragCur != dragCur; +} diff --git a/flutter_app/lib/container.dart b/flutter_app/lib/container.dart index 57d5a3a..ed22188 100644 --- a/flutter_app/lib/container.dart +++ b/flutter_app/lib/container.dart @@ -1,3 +1,4 @@ +import 'annotate/annotate_api.dart'; import 'auth/auth_view_model.dart'; import 'auth/session_store.dart'; import 'home/home_view_model.dart'; @@ -15,6 +16,7 @@ class AppContainer { late final AuthViewModel authViewModel; late final HomeViewModel homeViewModel; late final PaywallViewModel paywallViewModel; + late final AnnotateApi annotateApi; AppContainer() { sessionStore = SessionStore(); @@ -31,5 +33,6 @@ class AppContainer { PayChannel.alipay: AlipayService(orderApi: orderApi), }, ); + annotateApi = AnnotateApi(sessionStore: sessionStore); } } diff --git a/flutter_app/lib/home/home_screen.dart b/flutter_app/lib/home/home_screen.dart index 7a19ea8..dd46dc8 100644 --- a/flutter_app/lib/home/home_screen.dart +++ b/flutter_app/lib/home/home_screen.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../annotate/annotate_tasks_screen.dart'; import '../auth/session_store.dart'; import '../config/app_version.dart'; +import '../container.dart'; import 'home_view_model.dart'; /// 主界面:当前账号到期时间 + 搜索按钮(强制服务端校验后进相机)+ 充值入口 @@ -49,6 +51,14 @@ class _HomeScreenState extends State { if (mounted) context.read().refresh(); } + /// 标注赚时长:处理众包标注任务获取使用时长 + Future _onAnnotate() async { + final api = context.read().annotateApi; + await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => AnnotateTasksScreen(api: api), + )); + } + Future _toLogin() async { final container = context.read(); await container.clear(); @@ -148,9 +158,20 @@ class _HomeScreenState extends State { SizedBox( height: 52, child: OutlinedButton.icon( + onPressed: _onAnnotate, + icon: const Icon(Icons.edit_note), + label: const Text('标注赚时长', + style: TextStyle(fontSize: 16)), + ), + ), + const SizedBox(height: 12), + SizedBox( + height: 44, + child: TextButton.icon( onPressed: _onRecharge, - icon: const Icon(Icons.payment), - label: const Text('充值', style: TextStyle(fontSize: 16)), + icon: const Icon(Icons.payment, size: 18), + label: + const Text('充值', style: TextStyle(fontSize: 14)), ), ), if (vm.error != null && vm.license != null) ...[ diff --git a/server/README.md b/server/README.md index 593e140..80502d2 100644 --- a/server/README.md +++ b/server/README.md @@ -11,14 +11,15 @@ | 支付回调 | 微信支付 / 支付宝异步回调验签落授权(`/api/v1/payment` 分组,按渠道应答格式直返,不做统一包装) | | 订单确认 | 客户端支付成功后 `POST /api/v1/orders/{orderId}/confirm` 幂等通知,加速授权刷新 | | 授权查询 | `GET /api/v1/license`(Bearer token)返回授权状态,App 识别入口强制服务端校验 | +| 标注众包赚时长 | App 用户处理管理端下发的标注任务获取使用时长:任务 = 管理端**勾选的具体图片集合**(图片粒度下发,2026-09-07;已下发图即从「未标注」tab 消失,停用任务释放未领取图回池),每次领取 10 张(锁定向、超时自动释放、已处理过的图不再分配),手机上画框提交 → 图片进「待审核」;每有效提交 10 张**即时到账 30 分钟**(license 到期时间分钟级顺延),**每日上限 2 小时**;质量惩罚:审核通过比例 <80%(已审核样本 ≥5)自动冻结领取资格 24 小时(时间戳对比天然自动解冻、统计重新累计),管理端可手动解冻;服务端 `annotate_record` 记录每用户每张图的处理明细(领取/提交快照/审核结果) | | 套餐 | `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 四级漏斗标注(全图扫描→空检切片扫描→VLM 提议候选区精修,见技术设计.md「预标注四级漏斗」)**,进度条展示在页顶;页顶另有「全量标注」按钮可手动重标全部图片(覆盖各图已有标注);点击原图/标注图弹窗放大,弹窗为**审核视图(不做手动画框)**:点击框选中,列表可确认疑似框/删除误检框/清空并保存——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;**数据清洗(2026-09-02)**:详情页「数据清洗」——按标注目标尺寸细档统计超配,超配桶内整图 dHash 多样性保留、其余进候选清单,执行=打「已排除训练集」标记(可恢复不删图),prepare_yolo 打包跳过 | +| 数据训练(唯一入口) | 后台管理端「数据训练」一个菜单承载数据集全流程,**双 tab(2026-09-07)**:「数据集」tab = 数据集卡片列表(封面图/描述/图片数/已标注数/**训练状态徽标**),「负样本」tab = 负样本库图片网格(上传/删除,见技术设计.md「负样本库」——训练打包时统一混入全部物种数据集);**卡片下方直接展示训练任务进度条与状态**(无独立训练页);详情页为**图片与标注一体视图**:分页(每页 20 条)逐行「原图 ‖ 标注图」对照展示;**图片不自动标注(2026-09-04 自动标注退场)**:标注唯一入口 = 勾选图片顶栏「预标」(RF-DETR 四级漏斗检测,见技术设计.md「预标注四级漏斗」),进度条展示在页顶;**预标完成进「待审核」,人工审核通过才「已标注」**(`dataset_image.review_status` 0 未标注/1 待审核/2 已审核 三态,训练集只收已审核图);点击原图/标注图弹窗放大进入标注编辑器(画框/确认/清理,保存即视为已审核);封面(上传/生成统一 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;**数据清洗(2026-09-02)**:详情页「数据清洗」——按标注目标尺寸细档统计超配,超配桶内整图 dHash 多样性保留、其余进候选清单,执行=打「已排除训练集」标记(可恢复不删图),prepare_yolo 打包跳过 | | 模型训练 | 从数据集卡片「开始训练」一键触发(参数 imgsz/epochs/batch/device 默认走 `config.yml` `training` 节点,部署级配置);**双档位(2026-09-03)**:一次发起按档位各建一条任务——高识别档 s(基座 `training.model`、imgsz `training.imgsz`=1280)/ 高性能档 n(基座 `training.modelN`=yolov8n.pt、imgsz `training.imgszN`=704),请求传 `variants:["s","n"]` 限定(省略=双档;n 档配置缺失时请求报错),epochs/batch/device 双档共用,任务带 `variant` 快照;**GPU 独占排队(2026-09-03)**:并发度 1 不变——已有 running 时新任务落 `queued` 排队(不再拒绝),10s 轮询在 running 结束后自动按创建顺序晋级启动、一次一个(训练机单 GPU 串行跑多档/多数据集),取消 running=杀进程、queued=直接置失败;进度/日志/指标监控(每 epoch 粒度);训练通道 `training` 节点可配置 subprocess(与 Go 服务同机直接起 python)/ ssh(异机执行,SSH 凭据取 `config.yml` `training.ssh` 节点);训练脚本 `server/training/train_server.py`(随项目迁移,2026-08-26)参数化(task.json 传 model/imgsz),产物(best.tflite/best.pt/曲线)拉回服务器;训练收尾自动做 **tflite 产物自检**(输入/输出 shape 校验,原 `inspect_tflite.py` 逻辑内嵌脚本),自检失败任务置失败并带出原因;`dump_graph.py` 留作训练机人工深度调试 | | 模型版本与热更新 | **每数据集每档位一个模型**(2026-09-03 双档位):训练成功后一键「发布」(训练任务操作列)——tflite 已由训练成功直写最终位置:s 档 `workspace/trainings/<文件名前缀>.tflite`、n 档 `<文件名前缀>_n.tflite`(前缀空回退数据集名),发布仅落 `model_version` 记录(sha256/大小/指标/类别名,带 `variant` 档位列);版本序列每数据集全局共用 m1.0.0 递增(s/n 交替发布走同一序列,无档位独立序列),`is_latest` 按 (数据集, 档位) 各记一条——发布只清同档位旧记录,s/n 两档互不影响,目录可分别发布、分别下发。管理端**无模型管理界面**(版本记录仅支撑客户端下发)。**App 模型热更新**:`GET /api/v1/app/update` 扩展返回 `models` 目录数组,客户端独立检查,新模型下载校验替换,失败回退旧模型——模型迭代不再重打包 APK | | 模型目录与多模型推理 | `GET /api/v1/models`(登录态)返回全部数据集当前生效模型(数据集/档位 `variant` s|n/版本/类别/大小/sha256/下载地址;**每数据集最多 2 条 = s/n 两档各自的 is_latest**),下载 URL s 档 `/download/trainings/<文件名前缀>.tflite`、n 档 `/download/trainings/<文件名前缀>_n.tflite`(前缀空回退数据集名);**App 模型管理页**用户自由下载/删除/启用模型,识别时**按当前识别档位(s 高识别 / n 高性能,全局切换)加载该档位已启用模型**并行推理 + 跨模型 NMS 合并(按类别名),内置 assets 模型兜底 | -| 标注 | **图片入库自动触发**:手动上传/AI 生成成功后,新增图自动调 `config.yml` `localAi` 节点配置的 AI 端点做 RF-DETR 检测(**四级漏斗(2026-09-04)**:全图扫描→空检自动升级切片扫描→仍空 VLM 提议候选区+RF-DETR 精修→全空写空标注等人工,切片块长边/重叠/切片阈值走 `localAi.tileSize`/`tileOverlap`/`tileThreshold`,见技术设计.md「预标注四级漏斗」;`label_task` 记录进度,页顶进度条展示;**localAi 未配置 → 上传/生成接口直接报错;已有标注任务在跑(忙)→ 不报错**,当前任务成功完成后自动补标未标注图)→ 扫描结果(**重叠去重**:NMS 风格按置信度降序保留,重叠比 > `localAi.overlapThreshold` 默认 0.3 的框剔除——重叠比 = 交叠面积/两框较小面积,RF-DETR 同目标常输出一大一小两框,此判据能命中,同目标只留置信度最高者)**直接写 `dataset_image.labels_json`**(覆盖该图已有标注,即重标语义);点击弹窗放大进入**审核视图(不做手动画框)**:点击框选中,列表确认疑似框/删除误检框/清空 → 保存即整体覆写 `dataset_image.labels_json`(YOLO 归一化 JSON 数组,AI 标注直写、人工仅审核确认);`POST /admin/label-tasks` 详情页「全量标注」按钮入口(另有自动触发),可发起全量/指定图重标;自动/手动/混合并存,训练前自动整理(prepare_yolo 逻辑在服务端) | +| 标注 | **无自动标注(2026-09-04 退场,用户定案)**:上传/生成入库不触发任何检测,`localAi` 未配置不再阻断入库;标注唯一入口 = 管理端勾选图片顶栏「预标」→ `POST /admin/label-tasks`(RF-DETR **四级漏斗**:全图扫描→空检自动升级切片扫描→仍空 VLM 提议候选区+RF-DETR 精修;切片参数走 `localAi.tileSize`/`tileOverlap`/`tileThreshold`,见技术设计.md「预标注四级漏斗」;扫描结果 minIoU 重叠去重后直写 `dataset_image.labels_json`,空检出写 `[]` 且 review_status 保持未标注);**预标完成 →「待审核」(review_status=1),人工审核通过才「已标注」(=2)**,训练集打包只收已审核图(prepareYoloSet 质量闸门);工作台弹窗人工画框/确认后保存即视为已审核;管理端对待审核图批量「通过/拒绝」(拒绝 = 清标注回未标注池,并计入对应 App 用户的低质统计,见「标注众包赚时长」) | ## 架构与数据流 @@ -46,14 +47,17 @@ Flutter App ── POST /auth/register|login ─► 账号注册/登录,签发 | 表 | 说明 | 关键字段 | |---|---|---| | `payment_order` | 支付订单 | `order_id`(PK)、`phone_num`、`plan_id`、`channel`(wechat/alipay)、`amount_cents`、`status`(created/paid/closed)、`wx_trade_no`(UNIQUE)、`alipay_trade_no`(UNIQUE)、`created_at`、`paid_at` | -| `license` | 手机号账号与授权 | `phone_num`(PK)、`password`(bcrypt)、`expires_at`(未充值 NULL)、`remark`(管理端备注)、`created_at`、`updated_at` | +| `license` | 手机号账号与授权 | `phone_num`(PK)、`password`(bcrypt)、`expires_at`(未充值 NULL;标注奖励分钟级顺延)、`annotate_frozen_until`(标注低质冻结到期标记,NULL/过期=正常)、`annotate_stats_since`(通过比例统计基线,冻结时重置实现解冻后重新累计)、`remark`(管理端备注)、`created_at`、`updated_at` | | `app_version` | App 版本管理 | `id`(PK)、`version`(x.y.z, UNIQUE)、`notes`(更新说明)、`created_at`、`updated_at`(下载地址不落表:APK 固定文件 `app.apkDir`/`observer-latest.apk`,默认 `./workspace/`) | -| `dataset` | 训练数据集 | `id`(PK)、`name`(UNIQUE)、`source`(manual/ai)、`image_count`、`labeled_count`、`status`(building/synced/labeled)、`cover`(封面文件名,UUID 命名 jpg,如 `9f2a...-xx.jpg`)、`description`、`created_at`、`updated_at`(图片文件在 `app.datasetDir`/`datasets//`,DB 只存元数据;AI 标注/训练机 SSH 配置走 `config.yml` 的 `localAi` / `training.ssh` 节点);**生成参数池(创建时 VLM 自动生成,界面不维护,可 `POST /datasets/gen-pools` 重新生成)**:`gen_species`(单值=数据集物种)、`gen_tone`(单值 轮廓色词 深色/浅色)、`gen_heights`(数值 站高cm,距离感公式用)、`gen_scenes`/`gen_actions`/`gen_occlusions`(JSON 数组 各≥3条)、`gen_classes`(单值 第二标注类别名="suspect",第一类别=gen_species,训练 data.yaml names);**单物种规则:每数据集只对应一个物种(生成图片固定按数据集名),不同物种拆到不同数据集** | -| `dataset_image` | 数据集图片 | `id`(PK)、`dataset_id`、`filename`、`source`(manual/ai)、`prompt`(AI 生成图记录提示词)、`labels_json`(标注 JSON 数组:YOLO 归一化 xywh+类别+置信度,AI 自动标注与人工标注同存、人工可修改/清理,null/''/'[]'=未标注)、`clean_excluded`(0/1,2026-09-02 数据清洗排除出训练集标记,prepare_yolo 打包跳过,可恢复)、`created_at` | +| `dataset` | 训练数据集 | `id`(PK)、`name`(UNIQUE)、`source`(manual/ai/**negative**=负样本库,2026-09-07:固定保留名 `__negative__`,训练打包时混入全部物种数据集当背景学习)、`image_count`、`labeled_count`、`status`(building/synced/labeled)、`cover`(封面文件名,UUID 命名 jpg,如 `9f2a...-xx.jpg`)、`description`、`created_at`、`updated_at`(图片文件在 `app.datasetDir`/`datasets//`,DB 只存元数据;AI 标注/训练机 SSH 配置走 `config.yml` 的 `localAi` / `training.ssh` 节点);**生成参数池(创建时 VLM 自动生成,界面不维护,可 `POST /datasets/gen-pools` 重新生成)**:`gen_species`(单值=数据集物种)、`gen_tone`(单值 轮廓色词 深色/浅色)、`gen_heights`(数值 站高cm,距离感公式用)、`gen_scenes`/`gen_actions`/`gen_occlusions`(JSON 数组 各≥3条)、`gen_classes`(单值 第二标注类别名="suspect",第一类别=gen_species,训练 data.yaml names);**单物种规则:每数据集只对应一个物种(生成图片固定按数据集名),不同物种拆到不同数据集** | +| `dataset_image` | 数据集图片 | `id`(PK)、`dataset_id`、`filename`、`source`(manual/ai)、`prompt`(AI 生成图记录提示词)、`labels_json`(标注 JSON 数组:YOLO 归一化 xywh+类别+置信度,AI 预标与人工标注同存、人工可修改/清理,null/''/'[]'=无框)、`review_status`(2026-09-04 审核三态:0 未标注/1 待审核/2 已审核;预标与 App 提交→1,人工保存与审核通过→2,拒绝清标注→0;训练集只收 2)、`clean_excluded`(0/1,2026-09-02 数据清洗排除出训练集标记,prepare_yolo 打包跳过,可恢复)、`annotate_task_id`(2026-09-07 众包下发的任务占用标记,0=未下发;下发即从「未标注」tab 消失,停用任务释放未领取图回 0)、`created_at` | | `model_training` | 训练任务 | `id`(PK)、`name`、`status`(queued/running/success/failed;queued=GPU 忙排队中,2026-09-03)、`dataset_id`、`variant`(s/n 档位,default s,2026-09-03)、`imgsz`/`epochs`/`batch`/`device`(参数快照)、`current_epoch`/`total_epochs`、`metrics`(JSON)、`log_tail`、`pid`、`error`、`started_at`/`finished_at`、`created_at` | | `model_version` | 模型版本(每数据集全局共用序列) | `id`(PK)、`dataset_id`、`variant`(s/n,default s;存量行迁移为 s)、`version`(m1.0.0 递增, 同数据集 UNIQUE,s/n 交替发布共用序列)、`training_id`、`metrics`(JSON)、`labels`(JSON 类别名数组)、`sha256`、`size_bytes`、`is_latest`(按 (数据集,档位) 各记一条)、`notes`、`created_at`(模型文件不落表:发布即写 `trainings/<文件名前缀>.tflite`(s)/`<文件名前缀>_n.tflite`(n,前缀空回退数据集名),客户端固定下载,无存档回退) | | `label_task` | 标注任务 | `id`(PK)、`dataset_id`、`filenames`(JSON 选中图片列表,NULL=全量)、`status`(running/done)、`total`/`done`、`created_at`、`finished_at` | | `gen_task` | AI 生成任务(异步批量) | `id`(PK)、`dataset_id`、`status`(running/done/failed)、`total`/`done`、`error`、`created_at`、`finished_at` | +| `annotate_task` | 标注众包任务(2026-09-04;2026-09-07 图片粒度下发) | `id`(PK)、`dataset_id`、`name`、`status`(published/stopped)、`created_at`(任务图集 = `dataset_image.annotate_task_id` 占用本任务 id 的图,不落任务表) | +| `annotate_record` | 用户标注记录(2026-09-04) | `id`(PK)、`phone_num`、`task_id`、`dataset_id`(领取时冗余,详情页过滤用)、`image_id`、`labels_json`(提交快照)、`status`(pending 领取锁/submitted/approved/rejected)、`created_at`、`submitted_at`、`reviewed_at`;UNIQUE(phone_num,image_id),partial unique(image_id) WHERE status='pending' | +| `reward_log` | 标注时长发放流水(2026-09-04) | `id`(PK)、`phone_num`、`minutes`、`task_id`、`granted_at`(日上限 = 自然日求和) | 建表与迁移见 `技术设计.md`(新库直接建表;存量库以 `PRAGMA user_version` 版本化迁移)。 @@ -180,7 +184,8 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow | 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(1248x704)封面(1 雄 1 雌并排,响应含 `coverGenerated`/`coverError`,失败可在编辑模式重新生成);模型生成图统一转 jpg 落盘) | -| GET | `/admin/datasets` | 数据集列表:`page/size` 分页,返回 `{total, list}`(含 imageCount/labeledCount/status/cover/description/**training 聚合状态**:最新训练记录的 status/currentEpoch/totalEpochs) | +| GET | `/admin/datasets` | 数据集列表:`page/size` 分页,返回 `{total, list}`(含 imageCount/labeledCount/status/cover/description/**training 聚合状态**:最新训练记录的 status/currentEpoch/totalEpochs);**排除负样本库**(source=negative,仅「负样本」tab 展示) | +| POST | `/admin/datasets/negative` | 获取负样本库(2026-09-07):不存在则自动创建(source=negative 固定名 `__negative__`),返回数据集记录;其 id 供既有 `/admin/datasets/upload`、`/admin/datasets/images`、`/admin/datasets/images/delete`、`/admin/datasets/image` 复用(负样本无标注/审核/清洗流程) | | 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 | @@ -198,7 +203,7 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow | POST | `/admin/datasets/sync` | 同步数据集到训练机 `{"id":1}`:按 `training` 通道推送图片到训练机 `datasetDir//`(subprocess 同机 cp、ssh 异机 scp/rsync);训练启动前自动执行 | | POST | `/admin/datasets/clean/preview` | 数据清洗预览 `{"datasetId":1,"quotas":{...}}`(quotas 各档配额缺省用代码默认):按标注框高占比细档(<2/2-3/3-4/4-6/6-8/8-12/12-20/>20%)统计分布与超配量,超配桶按图内最小确认目标尺寸升序优先保留(小目标样本稀缺先保)、再整图 dHash 去近重复,其余进 `candidates`(含主/最小目标占比 + 目标数 + 实拍生成标识);返回各档分布 + 候选清单 + 已排除列表;<2% 极远档固定豁免,仅 class1 疑似图/空检 `[]` 不入清单 | | POST | `/admin/datasets/clean/apply` | 清洗执行 `{"imageIds":[1,2],"exclude":true}`:批量置/清 `clean_excluded`(排除=打标记,prepare_yolo 打包跳过;false=恢复),不删文件不删标注 | -| POST | `/admin/label-tasks` | 发起预标注 `{"datasetId":1,"filenames":["a.jpg","b.jpg"]}`(详情页「全量标注」按钮入口,图片入库亦自动触发):**filenames 缺省=全量**,指定图则只扫描选中图(全量重标/单张修补);调 `config.yml` `localAi` 配置的 AI 端点 RF-DETR 逐张推理(common 池并行,未配置报错),扫描结果做**重叠去重**(NMS 风格按置信度降序保留,重叠比 > `localAi.overlapThreshold` 默认 0.3 的框剔除,同目标只留置信度最高者)后**直写 `dataset_image.labels_json`(重跑覆盖该图标注)**,`label_task` 记录进度 | +| POST | `/admin/label-tasks` | 发起预标注 `{"datasetId":1,"filenames":["a.jpg","b.jpg"]}`(详情页勾选图片「预标」按钮入口,**2026-09-04 起为标注唯一触发方式,入库不再自动标注**):**filenames 缺省=全量**,指定图则只扫描选中图(全量重标/单张修补);调 `config.yml` `localAi` 配置的 AI 端点 RF-DETR 逐张推理(common 池并行,未配置报错),扫描结果做**重叠去重**(NMS 风格按置信度降序保留,重叠比 > `localAi.overlapThreshold` 默认 0.3 的框剔除,同目标只留置信度最高者)后**直写 `dataset_image.labels_json`(重跑覆盖该图标注)**并置 review_status=1 待审核(空检出 `[]` 保持未标注),`label_task` 记录进度 | | GET | `/admin/label-tasks` | 标注任务列表:`page/size` 分页(含 status/total/done) | | GET | `/admin/label-tasks/detail` | 标注任务详情:返回数据集全部图片 + 每张标注框(`boxes`,YOLO 归一化 xywh + 置信度 + 类别) | | POST | `/admin/label-tasks/save` | 保存单张标注 `{"datasetId":1,"filename":"a.jpg","boxes":[{"class":0,"cx":0.5,"cy":0.4,"w":0.1,"h":0.2}]}`:整体覆写该图 `labels_json`(空 boxes=清空标注),返回该数据集当前 `labeledCount` | @@ -208,6 +213,21 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow | POST | `/admin/trainings/cancel` | 取消训练 `{"id":1}`:running=杀训练进程置 failed;queued=无进程直接置 failed | | POST | `/admin/trainings/publish` | 发布为最新模型 `{"id":1,"notes":"..."}`(仅 success):读训练成功已直写的 tflite(s 档 `<文件名前缀>.tflite` / n 档 `<文件名前缀>_n.tflite`)算 sha256/大小 → 插入 `model_version`(版本号数据集内递增 m1.0.0 → m1.0.1)+ 同档位旧版 `is_latest=0`(s/n 互不影响) | | GET | `/admin/label-workbench` | 标注工作台数据 `{"datasetId":1}`:数据集全部图片 + 每张标注框(`boxes`,labels_json 全量)——无历史标注任务时详情页工作台的数据源 | +| POST | `/admin/annotate-tasks` | 下发标注任务 `{"datasetId":1,"name":"...","imageIds":[...]}`(2026-09-07 图片粒度下发):勾选的未标注图批量占用(`annotate_task_id` 写任务 id;校验属本数据集/未标注/未占用,任一非法整单拒绝),**已下发图即从「未标注」tab 消失**;同数据集已有 published 任务时拒绝 | +| GET | `/admin/annotate-tasks` | 标注任务列表:`datasetId` 可选过滤(**无独立菜单——任务面板内嵌数据训练详情页「标注任务」tab**,同 tab 还有「下发标注任务」按钮),含数据集名/池余量/各状态记录数/状态 | +| POST | `/admin/annotate-tasks/stop` | 停用任务 `{"id":1}`:整体不可再领取(已领取未提交的可继续提交);**释放未被领取过的图**(清 `annotate_task_id`,回到「未标注」tab 可再次下发) | +| POST | `/admin/annotate-review` | 批量审核 `{"imageIds":[...],"approve":true}`(详情页「待审核」tab):通过 → review_status=2;拒绝 → 清 labels_json 回未标注池 + 对应 submitted 记录置 rejected(计入该用户低质统计:通过比例 <80% 且已审核样本 ≥5 → 冻结 24h,自动解冻) | +| GET | `/admin/annotate-records` | 用户标注记录分页:`datasetId/phone/taskId/status` 过滤,每用户每张图的领取/提交快照/审核结果(**「标注记录」tab 同样内嵌详情页**) | +| POST | `/admin/annotate-unfreeze` | 手动解冻 `{"phone":"..."}`:清冻结标记与统计基线 | + +### 标注众包(App,Bearer token) + +| 接口 | 说明 | +|---|---| +| GET | `/api/v1/annotate/tasks` 可领任务列表(published + 池余量),附我的统计:今日已得时长/上限、进度(已提交 x/10)、通过比例、冻结到期时间(冻结中不可领取) | +| POST | `/api/v1/annotate/claim` 领取 `{"taskId":1}`:分配 ≤10 张未处理过的**任务下发图**(池 = 任务占用的未标注图,2026-09-07 起任务为勾选图片集合;pending 锁定,超时惰性释放;返回图片 URL + 数据集物种名) | +| POST | `/api/v1/annotate/submit` 提交 `{"imageId":1,"boxes":[...]}`:写 labels_json + review_status=1 待审核 + 记录置 submitted;每累计 10 张即时发 30 分钟(到期时间顺延),当日累计超 2 小时不再发 | +| GET | `/api/v1/annotate/me` 我的标注统计:累计提交/通过/拒绝、通过比例、累计获得时长、今日已得、冻结状态 | 管理页面由 `server_admin/` 构建产物提供,访问 `http:///admin/`。金额均为整数分,前端展示 ÷100 转元。 diff --git a/server/biz/consts/consts.go b/server/biz/consts/consts.go index 91b4e17..d200e27 100644 --- a/server/biz/consts/consts.go +++ b/server/biz/consts/consts.go @@ -13,6 +13,17 @@ const ( TableLabelTask = "label_task" TableGenTask = "gen_task" + TableAnnotateTask = "annotate_task" + TableAnnotateRecord = "annotate_record" + TableRewardLog = "reward_log" + + // dataset_image.review_status 审核三态(技术设计.md「标注审核状态机」): + // 预标/App 提交 → 待审核;人工保存/审核通过 → 已审核;拒绝清标注 → 未标注(回任务池)。 + // 不变式:review_status=0 ⟹ labels_json 无框;训练集只收 ReviewApproved + ReviewImageNone = 0 + ReviewImagePending = 1 + ReviewImageApproved = 2 + // 订单状态机 created → paid(closed 仅超时/失败关闭) OrderStatusCreated = "created" OrderStatusPaid = "paid" @@ -47,6 +58,12 @@ const ( DatasetStatusLabeled = "labeled" DatasetStatusSynced = "synced" + // 负样本库(技术设计.md「负样本库」):source=negative 的特殊数据集,统一背景样本 + // 训练打包时混入全部物种数据集(空标签 = 背景);固定保留名同是目录名, + // AdminCreateDataset 拒绝用户使用该名,AdminDeleteDataset 拒绝删除整库 + DatasetSourceNegative = "negative" + NegativeDatasetName = "__negative__" + // 标注任务状态 LabelTaskRunning = "running" LabelTaskDone = "done" @@ -75,6 +92,27 @@ const ( // L3 候选区扩大倍数(VLM 坐标偏粗,扩大裁剪给 RF-DETR 足够上下文,钳制图片边界) VlmRegionExpand = 2.0 + // 标注众包与时长激励(技术设计.md「App 标注众包与时长激励」): + // 以下为 config.yml annotateReward 缺失/非法时的回退默认值,可配项含义见 config.yml 注释 + AnnotateClaimSize = 10 // 每次领取图片数 + AnnotateClaimTimeoutHour = 2 // 领取锁超时(pending 超时视为放弃,领取时惰性释放) + AnnotateRewardPerImages = 10 // 每累计提交 N 张发一次时长 + AnnotateRewardMinutes = 30 // 每次发放分钟数(expires_at 分钟级顺延) + AnnotateDailyCapMinutes = 120 // 每日发放上限(自然日) + AnnotateFreezeRatio = 0.8 // 通过比例低于该值触发冻结 + AnnotateFreezeMinReviewed = 5 // 触发冻结判定的最小已审核样本数(防小样本误冻) + AnnotateFreezeHours = 24 // 冻结时长(annotate_frozen_until 时间戳对比天然自动解冻) + + // 众包任务状态 + AnnotateTaskPublished = "published" + AnnotateTaskStopped = "stopped" + + // 用户标注记录状态机:领取 pending → 提交 submitted → 审核 approved/rejected + AnnotateRecordPending = "pending" + AnnotateRecordSubmitted = "submitted" + AnnotateRecordApproved = "approved" + AnnotateRecordRejected = "rejected" + // 模型版本号前缀(m1.0.0),同数据集内递增 ModelVersionPrefix = "m" diff --git a/server/biz/controller/admin.go b/server/biz/controller/admin.go index 1c5c2b5..3f9f994 100644 --- a/server/biz/controller/admin.go +++ b/server/biz/controller/admin.go @@ -82,6 +82,11 @@ func (c *cAdmin) DeleteDataset(ctx context.Context, req *dto.AdminDatasetDeleteR return service.Dataset.AdminDeleteDataset(ctx, req) } +// NegativeLibrary 获取负样本库(无则创建) +func (c *cAdmin) NegativeLibrary(ctx context.Context, req *dto.AdminDatasetNegativeReq) (*dto.AdminDatasetNegativeRes, error) { + return service.Dataset.AdminNegativeLibrary(ctx, req) +} + // UploadImages 上传图片 func (c *cAdmin) UploadImages(ctx context.Context, req *dto.AdminDatasetUploadReq) (*dto.AdminDatasetUploadRes, error) { return service.Dataset.AdminUploadImages(ctx, req) @@ -219,3 +224,35 @@ func (c *cAdmin) SaveLabel(ctx context.Context, req *dto.AdminLabelSaveReq) (*dt func (c *cAdmin) VlmReview(ctx context.Context, req *dto.AdminImageVlmReviewReq) (*dto.AdminImageVlmReviewRes, error) { return service.LabelTask.AdminImageVlmReview(ctx, req) } + +// ---------- 标注众包(2026-09-04) ---------- + +// CreateAnnotateTask 下发标注任务 +func (c *cAdmin) CreateAnnotateTask(ctx context.Context, req *dto.AdminAnnotateTaskCreateReq) (*dto.AdminAnnotateTaskCreateRes, error) { + return service.Annotate.AdminCreateTask(ctx, req) +} + +// ListAnnotateTasks 标注任务列表 +func (c *cAdmin) ListAnnotateTasks(ctx context.Context, req *dto.AdminAnnotateTaskListReq) (*dto.AdminAnnotateTaskListRes, error) { + return service.Annotate.AdminListTasks(ctx, req) +} + +// StopAnnotateTask 停用标注任务 +func (c *cAdmin) StopAnnotateTask(ctx context.Context, req *dto.AdminAnnotateTaskStopReq) (*dto.AdminAnnotateTaskStopRes, error) { + return service.Annotate.AdminStopTask(ctx, req) +} + +// AnnotateReview 批量审核标注(通过/拒绝) +func (c *cAdmin) AnnotateReview(ctx context.Context, req *dto.AdminAnnotateReviewReq) (*dto.AdminAnnotateReviewRes, error) { + return service.Annotate.AdminReview(ctx, req) +} + +// ListAnnotateRecords 用户标注记录列表 +func (c *cAdmin) ListAnnotateRecords(ctx context.Context, req *dto.AdminAnnotateRecordListReq) (*dto.AdminAnnotateRecordListRes, error) { + return service.Annotate.AdminListRecords(ctx, req) +} + +// UnfreezeAnnotate 手动解冻标注资格 +func (c *cAdmin) UnfreezeAnnotate(ctx context.Context, req *dto.AdminAnnotateUnfreezeReq) (*dto.AdminAnnotateUnfreezeRes, error) { + return service.Annotate.AdminUnfreeze(ctx, req) +} diff --git a/server/biz/controller/annotate.go b/server/biz/controller/annotate.go new file mode 100644 index 0000000..02653b5 --- /dev/null +++ b/server/biz/controller/annotate.go @@ -0,0 +1,47 @@ +package controller + +import ( + "context" + + "github.com/gogf/gf/v2/net/ghttp" + + "observer-server/biz/model/dto" + "observer-server/biz/service" +) + +// cAnnotate 标注众包接口(登录态,手机号取自 token) +type cAnnotate struct{} + +var Annotate = &cAnnotate{} + +// Tasks 可领任务列表 + 我的统计 +func (c *cAnnotate) Tasks(ctx context.Context, req *dto.AnnotateTaskListReq) (*dto.AnnotateTaskListRes, error) { + return service.Annotate.AppTaskList(ctx, req) +} + +// Claim 领取标注图片 +func (c *cAnnotate) Claim(ctx context.Context, req *dto.AnnotateClaimReq) (*dto.AnnotateClaimRes, error) { + return service.Annotate.AppClaim(ctx, req) +} + +// Submit 提交标注 +func (c *cAnnotate) Submit(ctx context.Context, req *dto.AnnotateSubmitReq) (*dto.AnnotateSubmitRes, error) { + return service.Annotate.AppSubmit(ctx, req) +} + +// Me 我的标注统计 +func (c *cAnnotate) Me(ctx context.Context, req *dto.AnnotateMeReq) (*dto.AnnotateMeRes, error) { + return service.Annotate.AppMe(ctx, req) +} + +// Image 标注图片访问(直写响应体:service 校验归属返回路径,controller 输出二进制; +// 登录态鉴权,与管理端 image 端点隔离) +func (c *cAnnotate) Image(ctx context.Context, req *dto.AnnotateImageReq) (*dto.AnnotateImageRes, error) { + path, err := service.Dataset.ImageFile(ctx, req.DatasetId, req.Filename) + if err != nil { + return nil, err + } + r := ghttp.RequestFromCtx(ctx) + r.Response.ServeFile(path) + return &dto.AnnotateImageRes{}, nil +} diff --git a/server/biz/dao/annotate_record.go b/server/biz/dao/annotate_record.go new file mode 100644 index 0000000..b2e7c3c --- /dev/null +++ b/server/biz/dao/annotate_record.go @@ -0,0 +1,264 @@ +package dao + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + "observer-server/biz/consts" + "observer-server/biz/model/entity" + "observer-server/common" +) + +// AnnotateRecord 用户标注记录表 DAO:pending 即领取锁(partial unique index 同图仅一条), +// UNIQUE(phone_num, image_id) 一人一图仅一次;拒绝不删记录(低质统计与审计依据)。 +type annotateRecordDao struct{} + +var AnnotateRecord = &annotateRecordDao{} + +func init() { + ctx := context.Background() + _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS annotate_record ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + phone_num TEXT NOT NULL, + task_id INTEGER NOT NULL, + dataset_id INTEGER NOT NULL DEFAULT 0, + image_id INTEGER NOT NULL, + labels_json TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL, + submitted_at TEXT, + reviewed_at TEXT, + UNIQUE (phone_num, image_id) +)`) + if err != nil { + panic(err) + } + // 存量表补列(领取时冗余数据集 id,管理端详情页按数据集过滤记录) + common.EnsureColumn(ctx, consts.TableAnnotateRecord, "dataset_id", "dataset_id INTEGER NOT NULL DEFAULT 0") + // 领取锁:同一张图同时只允许一条 pending(partial unique index,SQLite 原生支持) + if _, err := g.DB().Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_annotate_record_image_pending +ON annotate_record (image_id) WHERE status = 'pending'`); err != nil { + panic(err) + } +} + +// InsertInTx 事务内插入领取记录(锁竞争由 partial unique index 兜底,冲突即失败回滚) +func (d *annotateRecordDao) InsertInTx(ctx context.Context, tx gdb.TX, m *entity.AnnotateRecord) error { + _, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).TX(tx).Data(g.Map{ + "phone_num": m.PhoneNum, + "task_id": m.TaskId, + "dataset_id": m.DatasetId, + "image_id": m.ImageId, + "labels_json": m.LabelsJson, + "status": m.Status, + "created_at": m.CreatedAt, + }).Insert() + return err +} + +// DeleteExpiredPending 惰性释放过期领取锁(领取时调用;pending 未产生任何标注,直接删除) +func (d *annotateRecordDao) DeleteExpiredPending(ctx context.Context, before *gtime.Time) error { + _, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx). + Where("status", consts.AnnotateRecordPending). + WhereLT("created_at", before). + Delete() + return err +} + +// ListImageIdsByPhone 某用户全部记录的图片 id(领取时排除自己处理过的图,含全部状态) +func (d *annotateRecordDao) ListImageIdsByPhone(ctx context.Context, phone string) ([]int64, error) { + out, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx). + Where("phone_num", phone).Fields("image_id").Array() + if err != nil { + return nil, err + } + return gconvInt64Slice(out), nil +} + +// ListPendingImageIds 当前全部领取锁的图片 id(领取时排除他人锁定的图) +func (d *annotateRecordDao) ListPendingImageIds(ctx context.Context) ([]int64, error) { + out, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx). + Where("status", consts.AnnotateRecordPending).Fields("image_id").Array() + if err != nil { + return nil, err + } + return gconvInt64Slice(out), nil +} + +// ListPendingImageIdsByTask 某任务当前领取锁的图片 id(停用任务释放时保留这些图—— +// 已领取未提交的可继续提交;过期锁由调用方先经 DeleteExpiredPending 惰性清理) +func (d *annotateRecordDao) ListPendingImageIdsByTask(ctx context.Context, taskId int64) ([]int64, error) { + out, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx). + Where("task_id", taskId). + Where("status", consts.AnnotateRecordPending).Fields("image_id").Array() + if err != nil { + return nil, err + } + return gconvInt64Slice(out), nil +} + +// GetMyPending 某用户在某图上的领取记录(提交校验:必须存在且为 pending) +func (d *annotateRecordDao) GetMyPending(ctx context.Context, phone string, imageId int64) (*entity.AnnotateRecord, error) { + var one *entity.AnnotateRecord + err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx). + Where("phone_num", phone).Where("image_id", imageId). + Where("status", consts.AnnotateRecordPending).Scan(&one) + if err != nil { + return nil, err + } + return one, nil +} + +// MarkSubmitted 提交:写快照 + 置 submitted +func (d *annotateRecordDao) MarkSubmitted(ctx context.Context, id int64, labelsJson string, submittedAt *gtime.Time) error { + _, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).Where("id", id). + Data(g.Map{"labels_json": labelsJson, "status": consts.AnnotateRecordSubmitted, "submitted_at": submittedAt}). + Update() + return err +} + +// MarkReviewedByImages 按图片批量审核当前提交中的记录(通过→approved / 拒绝→rejected): +// 只命中 status=submitted 的记录(历史已审核记录不动) +func (d *annotateRecordDao) MarkReviewedByImages(ctx context.Context, imageIds []int64, status string, reviewedAt *gtime.Time) error { + if len(imageIds) == 0 { + return nil + } + _, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx). + WhereIn("image_id", imageIds). + Where("status", consts.AnnotateRecordSubmitted). + Data(g.Map{"status": status, "reviewed_at": reviewedAt}). + Update() + return err +} + +// ListSubmittedPhonesByImages 被审核图片对应的提交用户(审核后重算通过比例用,去重) +func (d *annotateRecordDao) ListSubmittedPhonesByImages(ctx context.Context, imageIds []int64) ([]string, error) { + if len(imageIds) == 0 { + return []string{}, nil + } + out, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx). + WhereIn("image_id", imageIds). + Where("status", consts.AnnotateRecordSubmitted). + Fields("DISTINCT phone_num").Array() + if err != nil { + return nil, err + } + phones := make([]string, 0, len(out)) + for _, v := range out { + if s := v.String(); s != "" { + phones = append(phones, s) + } + } + return phones, nil +} + +// StatusCount 按 task_id × status 聚合的任务记录数(任务列表进度展示) +type StatusCount struct { + TaskId int64 `orm:"task_id"` + Status string `orm:"status"` + Cnt int64 `orm:"cnt"` +} + +// CountByTaskIds 多任务各状态记录数(一次 GROUP BY;IN 按 ≤100 分批由调用方保证) +func (d *annotateRecordDao) CountByTaskIds(ctx context.Context, taskIds []int64) (map[int64]map[string]int64, error) { + out := make(map[int64]map[string]int64) + if len(taskIds) == 0 { + return out, nil + } + var rows []StatusCount + err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx). + WhereIn("task_id", taskIds). + Fields("task_id, status, COUNT(*) AS cnt"). + Group("task_id, status").Scan(&rows) + if err != nil { + return nil, err + } + for _, r := range rows { + if out[r.TaskId] == nil { + out[r.TaskId] = make(map[string]int64) + } + out[r.TaskId][r.Status] = r.Cnt + } + return out, nil +} + +// UserStats 用户统计:累计提交数 + 审核通过/拒绝数(reviewedSince 非空时只统计其后的审核—— +// 冻结重置基线后「重新累计」语义) +type UserStats struct { + SubmittedTotal int64 + Approved int64 + Rejected int64 +} + +// CountUserStats 用户统计查询 +func (d *annotateRecordDao) CountUserStats(ctx context.Context, phone string, reviewedSince *gtime.Time) (*UserStats, error) { + stats := &UserStats{} + // 累计提交 = 已产生标注的全部记录(submitted/approved/rejected;pending 是未处理的锁不算) + n, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx). + Where("phone_num", phone). + WhereIn("status", []string{consts.AnnotateRecordSubmitted, consts.AnnotateRecordApproved, consts.AnnotateRecordRejected}). + Count() + if err != nil { + return nil, err + } + stats.SubmittedTotal = int64(n) + base := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).Where("phone_num", phone) + if reviewedSince != nil { + base = base.WhereGTE("reviewed_at", reviewedSince) + } + approved, err := base.Clone().Where("status", consts.AnnotateRecordApproved).Count() + if err != nil { + return nil, err + } + rejected, err := base.Where("status", consts.AnnotateRecordRejected).Count() + if err != nil { + return nil, err + } + stats.Approved = int64(approved) + stats.Rejected = int64(rejected) + return stats, nil +} + +// PageByFilter 管理端记录分页(datasetId>0/phone 精确/taskId/status 过滤,id 倒序) +func (d *annotateRecordDao) PageByFilter(ctx context.Context, phone string, taskId, datasetId int64, status string, page, size int) ([]*entity.AnnotateRecord, int64, error) { + base := func() *gdb.Model { + m := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx) + if phone != "" { + m = m.Where("phone_num", phone) + } + if taskId > 0 { + m = m.Where("task_id", taskId) + } + if datasetId > 0 { + m = m.Where("dataset_id", datasetId) + } + if status != "" { + m = m.Where("status", status) + } + return m + } + total, err := base().Count() + if err != nil { + return nil, 0, err + } + var list []*entity.AnnotateRecord + err = base().OrderDesc("id").Limit((page-1)*size, size).Scan(&list) + if err != nil { + if common.IsNoRows(err) { + return []*entity.AnnotateRecord{}, int64(total), nil + } + return nil, 0, err + } + return list, int64(total), nil +} + +func gconvInt64Slice(vals gdb.Array) []int64 { + out := make([]int64, 0, len(vals)) + for _, v := range vals { + out = append(out, v.Int64()) + } + return out +} diff --git a/server/biz/dao/annotate_task.go b/server/biz/dao/annotate_task.go new file mode 100644 index 0000000..e385b0d --- /dev/null +++ b/server/biz/dao/annotate_task.go @@ -0,0 +1,114 @@ +package dao + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + + "observer-server/biz/consts" + "observer-server/biz/model/entity" + "observer-server/common" +) + +// AnnotateTask 标注众包任务表 DAO:管理端下发/停用,App 端只读 published。 +type annotateTaskDao struct{} + +var AnnotateTask = &annotateTaskDao{} + +func init() { + ctx := context.Background() + _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS annotate_task ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dataset_id INTEGER NOT NULL, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'published', + created_at TEXT NOT NULL +)`) + if err != nil { + panic(err) + } +} + +// InsertInTx 事务内下发任务(与批量占用同事务,service 编排多表一致性) +func (d *annotateTaskDao) InsertInTx(ctx context.Context, tx gdb.TX, m *entity.AnnotateTask) (int64, error) { + res, err := g.DB().Model(consts.TableAnnotateTask).Ctx(ctx).TX(tx).Data(g.Map{ + "dataset_id": m.DatasetId, + "name": m.Name, + "status": m.Status, + "created_at": m.CreatedAt, + }).Insert() + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// GetById 按主键取 +func (d *annotateTaskDao) GetById(ctx context.Context, id int64) (*entity.AnnotateTask, error) { + var one *entity.AnnotateTask + err := g.DB().Model(consts.TableAnnotateTask).Ctx(ctx).Where("id", id).Scan(&one) + if err != nil { + return nil, err + } + return one, nil +} + +// GetPublishedByDataset 某数据集当前 published 任务(下发查重:同数据集同时只允许一个开放任务) +func (d *annotateTaskDao) GetPublishedByDataset(ctx context.Context, datasetId int64) (*entity.AnnotateTask, error) { + var one *entity.AnnotateTask + err := g.DB().Model(consts.TableAnnotateTask).Ctx(ctx). + Where("dataset_id", datasetId). + Where("status", consts.AnnotateTaskPublished). + OrderDesc("id").Scan(&one) + if err != nil { + return nil, err + } + return one, nil +} + +// ListPublished 全部开放任务(App 任务列表) +func (d *annotateTaskDao) ListPublished(ctx context.Context) ([]*entity.AnnotateTask, error) { + var list []*entity.AnnotateTask + err := g.DB().Model(consts.TableAnnotateTask).Ctx(ctx). + Where("status", consts.AnnotateTaskPublished). + OrderDesc("id").Scan(&list) + if err != nil { + if common.IsNoRows(err) { + return []*entity.AnnotateTask{}, nil + } + return nil, err + } + return list, nil +} + +// Page 管理端任务分页(datasetId>0 按数据集过滤——详情页 tab 内嵌展示;id 倒序) +func (d *annotateTaskDao) Page(ctx context.Context, datasetId int64, page, size int) ([]*entity.AnnotateTask, int64, error) { + base := func() *gdb.Model { + m := g.DB().Model(consts.TableAnnotateTask).Ctx(ctx) + if datasetId > 0 { + m = m.Where("dataset_id", datasetId) + } + return m + } + total, err := base().Count() + if err != nil { + return nil, 0, err + } + var list []*entity.AnnotateTask + err = base().OrderDesc("id").Limit((page-1)*size, size).Scan(&list) + if err != nil { + if common.IsNoRows(err) { + return []*entity.AnnotateTask{}, int64(total), nil + } + return nil, 0, err + } + return list, int64(total), nil +} + +// Stop 停用任务(整体不可再领取;已领取未提交的可继续提交) +func (d *annotateTaskDao) Stop(ctx context.Context, id int64) error { + _, err := g.DB().Model(consts.TableAnnotateTask).Ctx(ctx).Where("id", id). + Data(g.Map{"status": consts.AnnotateTaskStopped}).Update() + return err +} diff --git a/server/biz/dao/dataset.go b/server/biz/dao/dataset.go index f564f10..2de9491 100644 --- a/server/biz/dao/dataset.go +++ b/server/biz/dao/dataset.go @@ -95,6 +95,22 @@ func (d *datasetDao) GetById(ctx context.Context, id int64) (*entity.Dataset, er return &e, nil } +// GetByIds 按主键批量查询(众包任务/记录组装用) +func (d *datasetDao) GetByIds(ctx context.Context, ids []int64) ([]*entity.Dataset, error) { + if len(ids) == 0 { + return []*entity.Dataset{}, nil + } + var list []*entity.Dataset + err := g.DB().Model(consts.TableDataset).Ctx(ctx).WhereIn("id", ids).Scan(&list) + if err != nil { + if common.IsNoRows(err) { + return []*entity.Dataset{}, nil + } + return nil, err + } + return list, nil +} + // GetByName 按名称查询(目录名即数据集名),不存在返回 nil func (d *datasetDao) GetByName(ctx context.Context, name string) (*entity.Dataset, error) { var e entity.Dataset @@ -217,9 +233,10 @@ func (d *datasetDao) DeleteById(ctx context.Context, id int64) error { } // PageByKeyword 数据集分页:名称模糊匹配(Like 命中目录名,数据库内仅元数据) -// 排序同 Page:序号升序,同号按创建时间倒序(id 降序) +// 排序同 Page:序号升序,同号按创建时间倒序(id 降序);负样本库不进列表(独立 tab 展示) func (d *datasetDao) PageByKeyword(ctx context.Context, keyword string, page, size int) ([]*entity.Dataset, int64, error) { - base := g.DB().Model(consts.TableDataset).Ctx(ctx).WhereLike("name", "%"+keyword+"%") + base := g.DB().Model(consts.TableDataset).Ctx(ctx).WhereLike("name", "%"+keyword+"%"). + Where("source !=", consts.DatasetSourceNegative) total, err := base.Count() if err != nil { return nil, 0, err @@ -248,9 +265,10 @@ func (d *datasetDao) ListAll(ctx context.Context) ([]*entity.Dataset, error) { return list, nil } -// Page 数据集分页:序号升序,同号按创建时间倒序(id 降序) +// Page 数据集分页:序号升序,同号按创建时间倒序(id 降序);负样本库不进列表(独立 tab 展示) func (d *datasetDao) Page(ctx context.Context, page, size int) ([]*entity.Dataset, int64, error) { - base := g.DB().Model(consts.TableDataset).Ctx(ctx) + base := g.DB().Model(consts.TableDataset).Ctx(ctx). + Where("source !=", consts.DatasetSourceNegative) total, err := base.Count() if err != nil { return nil, 0, err diff --git a/server/biz/dao/dataset_image.go b/server/biz/dao/dataset_image.go index 72dd88d..18e85e4 100644 --- a/server/biz/dao/dataset_image.go +++ b/server/biz/dao/dataset_image.go @@ -36,6 +36,16 @@ func init() { common.EnsureColumn(ctx, consts.TableDatasetImage, "labels_json", "labels_json TEXT") common.EnsureColumn(ctx, consts.TableDatasetImage, "animal_count", "animal_count INTEGER NOT NULL DEFAULT 0") common.EnsureColumn(ctx, consts.TableDatasetImage, "clean_excluded", "clean_excluded INTEGER NOT NULL DEFAULT 0") + common.EnsureColumn(ctx, consts.TableDatasetImage, "review_status", "review_status INTEGER NOT NULL DEFAULT 0") + // 存量库迁移:众包任务图片粒度下发占用标记(2026-09-07,0=未下发) + common.EnsureColumn(ctx, consts.TableDatasetImage, "annotate_task_id", "annotate_task_id INTEGER NOT NULL DEFAULT 0") + // 存量迁移(幂等):加列后既有标注统一视为已审定稿(非空框→2);上线后不变式 + // review_status=0 ⟹ labels_json 无框 恒成立(拒绝清框、提交/预标置 1、审核置 2), + // 故本 UPDATE 只会命中存量行,不会误改新数据 + if _, err := g.DB().Exec(ctx, `UPDATE dataset_image SET review_status = 2 +WHERE review_status = 0 AND labels_json IS NOT NULL AND labels_json != '' AND labels_json != '[]'`); err != nil { + panic(err) + } // 存量库迁移:删除 prompt 列(生成提示词不再存储,2026-09-02 决策) cols, err := g.DB().GetAll(ctx, "PRAGMA table_info(dataset_image)") if err != nil { @@ -94,16 +104,15 @@ func (d *datasetImageDao) ListByDataset(ctx context.Context, datasetId int64) ([ return list, nil } -// ListUnlabeledByDataset 从未扫描过的图片(labels_json 为 null 或空串;自动补标轮次用)。 -// '[]'(已自动扫描但检出为空)不在此列:空检出图重扫结果不变,若仍入池,自动补标的 -// 「排除本轮已扫图」只换子集不灭池,会乒乓死循环(2026-09-02 兔子 3 张空检出实测: -// {9}→{10,17}→{9}… 无限交替续跑,每轮白打一次 RF-DETR);空检出图仍在管理端未标注 -// tab 等人工画框,重扫走手动触发(AdminStartLabelTask 按文件名批量或全量)。 -func (d *datasetImageDao) ListUnlabeledByDataset(ctx context.Context, datasetId int64) ([]*entity.DatasetImage, error) { +// ListPoolByTask 众包任务池(技术设计.md「App 标注众包与时长激励」;2026-09-07 图片粒度下发): +// 本任务占用(annotate_task_id=taskId)且未审核未清洗排除的图;待审核(1)/已审核(2)不进池—— +// 分别等管理端审核与已定稿 +func (d *datasetImageDao) ListPoolByTask(ctx context.Context, taskId int64) ([]*entity.DatasetImage, error) { var list []*entity.DatasetImage err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx). - Where("dataset_id", datasetId). - Where("labels_json IS NULL OR labels_json = ''"). + Where("annotate_task_id", taskId). + Where("review_status", consts.ReviewImageNone). + Where("clean_excluded", 0). OrderAsc("id").Scan(&list) if err != nil { if common.IsNoRows(err) { @@ -114,6 +123,66 @@ func (d *datasetImageDao) ListUnlabeledByDataset(ctx context.Context, datasetId return list, nil } +// CountPoolByTaskIds 多任务池余量(一次 GROUP BY;众包任务列表展示用) +func (d *datasetImageDao) CountPoolByTaskIds(ctx context.Context, taskIds []int64) (map[int64]int64, error) { + out := make(map[int64]int64) + if len(taskIds) == 0 { + return out, nil + } + var rows []struct { + AnnotateTaskId int64 `orm:"annotate_task_id"` + Cnt int64 `orm:"cnt"` + } + err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx). + WhereIn("annotate_task_id", taskIds). + Where("review_status", consts.ReviewImageNone). + Where("clean_excluded", 0). + Fields("annotate_task_id, COUNT(*) AS cnt"). + Group("annotate_task_id"). + Scan(&rows) + if err != nil { + return nil, err + } + for _, r := range rows { + out[r.AnnotateTaskId] = r.Cnt + } + return out, nil +} + +// OccupyByTask 下发占用:批量写 annotate_task_id(Serial 内调用,防与并发下发竞态;IN ≤100 分批) +func (d *datasetImageDao) OccupyByTask(ctx context.Context, ids []int64, taskId int64) error { + for start := 0; start < len(ids); start += 100 { + end := start + 100 + if end > len(ids) { + end = len(ids) + } + if _, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx). + WhereIn("id", ids[start:end]). + Where("annotate_task_id", 0). + Data(g.Map{"annotate_task_id": taskId}).Update(); err != nil { + return err + } + } + return nil +} + +// ReleaseByTask 停用释放:清指定图的任务占用标记回未标注(仅当仍占用本任务,防误清新任务标记;IN ≤100 分批) +func (d *datasetImageDao) ReleaseByTask(ctx context.Context, ids []int64, taskId int64) error { + for start := 0; start < len(ids); start += 100 { + end := start + 100 + if end > len(ids) { + end = len(ids) + } + if _, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx). + WhereIn("id", ids[start:end]). + Where("annotate_task_id", taskId). + Data(g.Map{"annotate_task_id": 0}).Update(); err != nil { + return err + } + } + return nil +} + // GetByIds 按 id 批量取(删除图片定位文件用) func (d *datasetImageDao) GetByIds(ctx context.Context, ids []int64) ([]*entity.DatasetImage, error) { var list []*entity.DatasetImage @@ -154,11 +223,30 @@ func (d *datasetImageDao) UpdateLabels(ctx context.Context, id int64, labelsJson return err } -// CountLabeledByDataset 某数据集已标注图片数(labels_json 非空数组的图片行数) +// UpdateLabelsAndReview 覆写标注 JSON 并同步审核状态(预标/AdminLabelSave/App 提交共用): +// 预标检出/人工保存/App 提交 → 1;人工清空保存 → 0(回到未标注池) +func (d *datasetImageDao) UpdateLabelsAndReview(ctx context.Context, id int64, labelsJson string, reviewStatus int) error { + _, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).Where("id", id). + Data(g.Map{"labels_json": labelsJson, "review_status": reviewStatus}).Update() + return err +} + +// SetReviewStatus 只改审核状态(审核通过=2 / 拒绝先清标注再置 0,两步由 service 编排) +func (d *datasetImageDao) SetReviewStatus(ctx context.Context, ids []int64, reviewStatus int) error { + if len(ids) == 0 { + return nil + } + _, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).WhereIn("id", ids). + Data(g.Map{"review_status": reviewStatus}).Update() + return err +} + +// CountLabeledByDataset 某数据集已审核图片数(review_status=2;2026-09-04 起已标注口径 +// = 人工审核通过,labels_json 非空只是必要条件) func (d *datasetImageDao) CountLabeledByDataset(ctx context.Context, datasetId int64) (int64, error) { n, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx). Where("dataset_id", datasetId). - Where("labels_json IS NOT NULL AND labels_json != '' AND labels_json != '[]'"). + Where("review_status", consts.ReviewImageApproved). Count() return int64(n), err } @@ -184,7 +272,7 @@ func (d *datasetImageDao) CountStatByDatasets(ctx context.Context, datasetIds [] } err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx). WhereIn("dataset_id", datasetIds[start:end]). - Fields("dataset_id, COUNT(*) AS cnt, SUM(CASE WHEN labels_json IS NOT NULL AND labels_json != '' AND labels_json != '[]' THEN 1 ELSE 0 END) AS labeled_cnt"). + Fields("dataset_id, COUNT(*) AS cnt, SUM(CASE WHEN review_status = 2 THEN 1 ELSE 0 END) AS labeled_cnt"). Group("dataset_id"). Scan(&rows) if err != nil { diff --git a/server/biz/dao/license.go b/server/biz/dao/license.go index 0db0d70..ce5eb26 100644 --- a/server/biz/dao/license.go +++ b/server/biz/dao/license.go @@ -32,6 +32,9 @@ func init() { panic(err) } common.EnsureColumn(ctx, consts.TableLicense, "remark", "remark TEXT") + // 标注众包(2026-09-04):冻结到期标记 + 通过比例统计基线(技术设计.md「App 标注众包与时长激励」) + common.EnsureColumn(ctx, consts.TableLicense, "annotate_frozen_until", "annotate_frozen_until TEXT") + common.EnsureColumn(ctx, consts.TableLicense, "annotate_stats_since", "annotate_stats_since TEXT") } // GetByPhone 按手机号查询(走缓存,键含手机号) @@ -110,6 +113,28 @@ func (d *licenseDao) ClearAuthInTx(ctx context.Context, tx gdb.TX, phone string) return err } +// ExtendExpiresInTx 事务内写到期时间(标注奖励分钟级顺延;新值由 service 按 max(now, expires_at) 计算) +func (d *licenseDao) ExtendExpiresInTx(ctx context.Context, tx gdb.TX, phone string, expiresAt any) error { + _, err := g.DB().Model(consts.TableLicense).Ctx(ctx).TX(tx). + Where("phone_num", phone). + Data(gdb.Map{"expires_at": expiresAt, "updated_at": gtime.Now()}). + Update() + return err +} + +// UpdateAnnotateState 写标注冻结标记与统计基线(传 nil 即清除;写后清缓存) +func (d *licenseDao) UpdateAnnotateState(ctx context.Context, phone string, frozenUntil, statsSince any) error { + _, err := g.DB().Model(consts.TableLicense).Ctx(ctx). + Where("phone_num", phone). + Data(gdb.Map{"annotate_frozen_until": frozenUntil, "annotate_stats_since": statsSince, "updated_at": gtime.Now()}). + Update() + if err != nil { + return err + } + common.ClearCache(ctx, "license:"+phone) + return nil +} + // SetRemarkInTx 事务内写备注(管理端 remark,空串即清空):不覆盖密码/授权字段 func (d *licenseDao) SetRemarkInTx(ctx context.Context, tx gdb.TX, phone, remark string) error { _, err := g.DB().Model(consts.TableLicense).Ctx(ctx).TX(tx). diff --git a/server/biz/dao/reward_log.go b/server/biz/dao/reward_log.go new file mode 100644 index 0000000..2a11c4a --- /dev/null +++ b/server/biz/dao/reward_log.go @@ -0,0 +1,64 @@ +package dao + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + "observer-server/biz/consts" +) + +// RewardLog 标注时长发放流水表 DAO:只增不改(审计);日上限按自然日求和。 +type rewardLogDao struct{} + +var RewardLog = &rewardLogDao{} + +func init() { + ctx := context.Background() + _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS reward_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + phone_num TEXT NOT NULL, + minutes INTEGER NOT NULL, + task_id INTEGER NOT NULL DEFAULT 0, + granted_at TEXT NOT NULL +)`) + if err != nil { + panic(err) + } +} + +// InsertInTx 事务内落一条发放流水(与 expires_at 顺延同事务保证原子) +func (d *rewardLogDao) InsertInTx(ctx context.Context, tx gdb.TX, phone string, minutes int, taskId int64, grantedAt *gtime.Time) error { + _, err := g.DB().Model(consts.TableRewardLog).Ctx(ctx).TX(tx).Data(g.Map{ + "phone_num": phone, + "minutes": minutes, + "task_id": taskId, + "granted_at": grantedAt, + }).Insert() + return err +} + +// SumMinutesByPhoneSince 某时间点(含)之后累计发放分钟数(日上限 = 传当日 0 点) +func (d *rewardLogDao) SumMinutesByPhoneSince(ctx context.Context, phone string, since *gtime.Time) (int, error) { + v, err := g.DB().Model(consts.TableRewardLog).Ctx(ctx). + Where("phone_num", phone). + WhereGTE("granted_at", since). + Fields("COALESCE(SUM(minutes), 0) AS total").Value() + if err != nil { + return 0, err + } + return v.Int(), nil +} + +// SumMinutesByPhone 累计发放分钟数(我的统计) +func (d *rewardLogDao) SumMinutesByPhone(ctx context.Context, phone string) (int, error) { + v, err := g.DB().Model(consts.TableRewardLog).Ctx(ctx). + Where("phone_num", phone). + Fields("COALESCE(SUM(minutes), 0) AS total").Value() + if err != nil { + return 0, err + } + return v.Int(), nil +} diff --git a/server/biz/model/dto/annotate.go b/server/biz/model/dto/annotate.go new file mode 100644 index 0000000..7af3b0a --- /dev/null +++ b/server/biz/model/dto/annotate.go @@ -0,0 +1,204 @@ +package dto + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// 标注众包与时长激励(技术设计.md「App 标注众包与时长激励」): +// 管理端 = 下发/停用任务、批量审核、用户标注记录、手动解冻;App = 任务列表/领取/提交/我的统计。 + +// ---------- 管理端 ---------- + +// AdminAnnotateTaskCreateReq 下发标注任务(2026-09-07 图片粒度:勾选的具体未标注图集合即任务图集, +// 下发即占用(annotate_task_id 写任务 id)从未标注 tab 消失,停用任务释放未领取图) +type AdminAnnotateTaskCreateReq struct { + g.Meta `path:"/annotate-tasks" method:"post" summary:"下发标注任务" tags:"管理端"` + DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集"` + Name string `json:"name" v:"required|length:1,50" dc:"任务名"` + ImageIds []int64 `json:"imageIds" v:"required|min-length:1" dc:"勾选的未标注图片 id 列表"` +} + +type AdminAnnotateTaskCreateRes struct { + Id int64 `json:"id"` +} + +// AdminAnnotateTaskListReq 标注任务列表(datasetId 缺省=全部;详情页 tab 内嵌时按数据集过滤) +type AdminAnnotateTaskListReq struct { + g.Meta `path:"/annotate-tasks" method:"get" summary:"标注任务列表" tags:"管理端"` + DatasetId int64 `json:"datasetId" v:"integer|min:0" dc:"数据集过滤,0=全部"` + Page int `json:"page" v:"integer|min:1" dc:"页码,默认 1"` + Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"` +} + +// AdminAnnotateTaskItem 任务条目:池余量 + 各状态记录数 +type AdminAnnotateTaskItem struct { + Id int64 `json:"id"` + DatasetId int64 `json:"datasetId"` + DatasetName string `json:"datasetName"` + Name string `json:"name"` + Status string `json:"status"` + PoolRemain int64 `json:"poolRemain"` + Pending int64 `json:"pending"` + Submitted int64 `json:"submitted"` + Approved int64 `json:"approved"` + Rejected int64 `json:"rejected"` + CreatedAt *gtime.Time `json:"createdAt"` +} + +type AdminAnnotateTaskListRes struct { + Total int64 `json:"total"` + List []*AdminAnnotateTaskItem `json:"list"` +} + +// AdminAnnotateTaskStopReq 停用任务 +type AdminAnnotateTaskStopReq struct { + g.Meta `path:"/annotate-tasks/stop" method:"post" summary:"停用标注任务" tags:"管理端"` + Id int64 `json:"id" v:"required|min:1" dc:"任务 id"` +} + +type AdminAnnotateTaskStopRes struct{} + +// AdminAnnotateReviewReq 批量审核:通过 → review_status=2;拒绝 → 清标注回未标注池 + +// 对应 submitted 记录置 rejected(触发低质统计/冻结判定) +type AdminAnnotateReviewReq struct { + g.Meta `path:"/annotate-review" method:"post" summary:"批量审核标注" tags:"管理端"` + ImageIds []int64 `json:"imageIds" v:"required" dc:"图片 id 列表"` + Approve bool `json:"approve" dc:"true=通过 false=拒绝"` +} + +type AdminAnnotateReviewRes struct { + Affected int64 `json:"affected"` // 实际变更的图片数(过滤掉不存在/非待审核) + FrozenPhones []string `json:"frozenPhones"` // 本次触发冻结的用户(空=无) +} + +// AdminAnnotateRecordListReq 用户标注记录分页(每用户每张图的处理明细;datasetId 缺省=全部) +type AdminAnnotateRecordListReq struct { + g.Meta `path:"/annotate-records" method:"get" summary:"用户标注记录列表" tags:"管理端"` + DatasetId int64 `json:"datasetId" v:"integer|min:0" dc:"数据集过滤,0=全部"` + Phone string `json:"phone" v:"length:0,20" dc:"手机号精确过滤"` + TaskId int64 `json:"taskId" v:"integer|min:0" dc:"任务过滤,0=全部"` + Status string `json:"status" v:"in:,pending,submitted,approved,rejected" dc:"状态过滤,空=全部"` + Page int `json:"page" v:"integer|min:1" dc:"页码,默认 1"` + Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"` +} + +// AdminAnnotateRecordItem 记录条目(附图片文件名与数据集名,内存组装) +type AdminAnnotateRecordItem struct { + Id int64 `json:"id"` + PhoneNum string `json:"phoneNum"` + TaskId int64 `json:"taskId"` + DatasetName string `json:"datasetName"` + ImageId int64 `json:"imageId"` + Filename string `json:"filename"` + ImageUrl string `json:"imageUrl"` + Status string `json:"status"` + Boxes []*AdminLabelBox `json:"boxes"` + CreatedAt *gtime.Time `json:"createdAt"` + SubmittedAt *gtime.Time `json:"submittedAt"` + ReviewedAt *gtime.Time `json:"reviewedAt"` +} + +type AdminAnnotateRecordListRes struct { + Total int64 `json:"total"` + List []*AdminAnnotateRecordItem `json:"list"` +} + +// AdminAnnotateUnfreezeReq 手动解冻 +type AdminAnnotateUnfreezeReq struct { + g.Meta `path:"/annotate-unfreeze" method:"post" summary:"手动解冻标注资格" tags:"管理端"` + Phone string `json:"phone" v:"required|length:6,20" dc:"手机号"` +} + +type AdminAnnotateUnfreezeRes struct{} + +// ---------- App(Bearer token,手机号取登录态) ---------- + +// AnnotateStats 我的标注统计(任务列表/我的页共用) +type AnnotateStats struct { + SubmittedTotal int64 `json:"submittedTotal"` // 累计有效提交 + Approved int64 `json:"approved"` // 审核通过 + Rejected int64 `json:"rejected"` // 审核拒绝 + ApproveRatio float64 `json:"approveRatio"` // 当前统计基线内通过比例(0~1,无样本=1) + ProgressDone int64 `json:"progressDone"` // 距下次奖励进度(累计提交 mod perImages) + RewardPerImages int `json:"rewardPerImages"` // 每次奖励所需提交数 + RewardMinutes int `json:"rewardMinutes"` // 每次奖励分钟数 + TotalEarnedMinutes int `json:"totalEarnedMinutes"` // 累计获得分钟数 + TodayEarnedMinutes int `json:"todayEarnedMinutes"` // 今日已得(自然日) + DailyCapMinutes int `json:"dailyCapMinutes"` // 每日上限 + FrozenUntil *gtime.Time `json:"frozenUntil"` // 冻结到期(nil=未冻结) + ExpiresAt *gtime.Time `json:"expiresAt"` // 当前授权到期 +} + +// AnnotateTaskListReq App 可领任务列表 +type AnnotateTaskListReq struct { + g.Meta `path:"/annotate/tasks" method:"get" summary:"可领标注任务列表" tags:"标注众包"` +} + +// AnnotateTaskItem 任务条目(含池余量;冻结中仍展示但不可领取) +type AnnotateTaskItem struct { + Id int64 `json:"id"` + Name string `json:"name"` + DatasetId int64 `json:"datasetId"` + DatasetName string `json:"datasetName"` + Species string `json:"species"` + PoolRemain int64 `json:"poolRemain"` +} + +type AnnotateTaskListRes struct { + List []*AnnotateTaskItem `json:"list"` + Stats *AnnotateStats `json:"stats"` +} + +// AnnotateClaimReq 领取:分配 ≤ClaimSize 张未处理过的池内图(pending 锁定) +type AnnotateClaimReq struct { + g.Meta `path:"/annotate/claim" method:"post" summary:"领取标注图片" tags:"标注众包"` + TaskId int64 `json:"taskId" v:"required|min:1" dc:"任务 id"` +} + +// AnnotateClaimImage 分配的单张图(宽高供 App 端 canvas 画框) +type AnnotateClaimImage struct { + ImageId int64 `json:"imageId"` + Url string `json:"url"` + Width int `json:"width"` + Height int `json:"height"` +} + +type AnnotateClaimRes struct { + Images []*AnnotateClaimImage `json:"images"` + Species string `json:"species"` +} + +// AnnotateSubmitReq 提交标注:写 labels_json + review_status=1 待审核,每累计 RewardPerImages +// 张发 RewardMinutes 分钟(当日超 DailyCapMinutes 不再发);boxes 可为空数组(画面无目标主张) +type AnnotateSubmitReq struct { + g.Meta `path:"/annotate/submit" method:"post" summary:"提交标注" tags:"标注众包"` + ImageId int64 `json:"imageId" v:"required|min:1" dc:"图片 id"` + Boxes []*AdminLabelBox `json:"boxes" dc:"标注框(空数组=画面无目标)"` +} + +type AnnotateSubmitRes struct { + Granted bool `json:"granted"` // 本次是否触发发放 + Minutes int `json:"minutes"` // 本次发放分钟数(granted 时有效) + TodayEarnedMinutes int `json:"todayEarnedMinutes"` // 今日已得 + ExpiresAt *gtime.Time `json:"expiresAt"` // 发放后的授权到期 + ProgressDone int64 `json:"progressDone"` // 距下次奖励进度 +} + +// AnnotateMeReq 我的标注统计 +type AnnotateMeReq struct { + g.Meta `path:"/annotate/me" method:"get" summary:"我的标注统计" tags:"标注众包"` +} + +type AnnotateMeRes struct { + Stats *AnnotateStats `json:"stats"` +} + +// AnnotateImageReq 标注图片访问(登录态;controller 直写响应体,服务端校验图片归属) +type AnnotateImageReq struct { + g.Meta `path:"/annotate/image" method:"get" summary:"标注图片访问" tags:"标注众包"` + DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集"` + Filename string `json:"filename" v:"required" dc:"文件名"` +} + +type AnnotateImageRes struct{} diff --git a/server/biz/model/dto/training.go b/server/biz/model/dto/training.go index 579f8f7..97869d2 100644 --- a/server/biz/model/dto/training.go +++ b/server/biz/model/dto/training.go @@ -125,6 +125,18 @@ type AdminDatasetDeleteReq struct { type AdminDatasetDeleteRes struct{} +// AdminDatasetNegativeReq 获取负样本库(技术设计.md「负样本库」):固定保留名特殊数据集, +// 不存在则自动创建;管理端「负样本」tab 进入时调用,返回 id 供既有上传/图片列表/删除接口复用 +type AdminDatasetNegativeReq struct { + g.Meta `path:"/datasets/negative" method:"post" summary:"获取负样本库(无则创建)" tags:"管理端"` +} + +type AdminDatasetNegativeRes struct { + Id int64 `json:"id"` + Name string `json:"name"` + ImageCount int64 `json:"imageCount"` +} + // AdminDatasetUploadReq 上传图片(multipart 多文件;重名跳过并计数) type AdminDatasetUploadReq struct { g.Meta `path:"/datasets/upload" method:"post" summary:"上传图片" tags:"管理端" mime:"multipart/form-data"` @@ -182,14 +194,16 @@ type AdminDatasetImagesReq struct { } // AdminImageItem 图片条目(url 为管理端图片访问地址,前端拼 apiBaseUrl 使用; -// cleanExcluded=1 的图归「已清洗」tab——数据清洗排除,训练集打包跳过,可恢复) +// cleanExcluded=1 的图归「已清洗」tab——数据清洗排除,训练集打包跳过,可恢复; +// annotateTaskId>0 的图已下发给众包任务,不进「未标注」tab) type AdminImageItem struct { - Id int64 `json:"id"` - Filename string `json:"filename"` - Source string `json:"source"` // manual | ai - Url string `json:"url"` - CleanExcluded int `json:"cleanExcluded"` // 1=已清洗排除 - CreatedAt *gtime.Time `json:"createdAt"` + Id int64 `json:"id"` + Filename string `json:"filename"` + Source string `json:"source"` // manual | ai + Url string `json:"url"` + CleanExcluded int `json:"cleanExcluded"` // 1=已清洗排除 + AnnotateTaskId int64 `json:"annotateTaskId"` // 众包任务占用标记(0=未下发) + CreatedAt *gtime.Time `json:"createdAt"` } type AdminDatasetImagesRes struct { @@ -367,7 +381,7 @@ type AdminLabelTaskListRes struct { type AdminLabelTaskStartReq struct { g.Meta `path:"/label-tasks" method:"post" summary:"发起预标注" tags:"管理端"` DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"` - Filenames []string `json:"filenames" v:"length:0,500" dc:"选中图片列表(多选批量);缺省=全量扫描"` + Filenames []string `json:"filenames" dc:"选中图片列表(多选批量);缺省=全量扫描"` } type AdminLabelTaskStartRes struct { @@ -407,14 +421,15 @@ type AdminLabelBox struct { Class int `json:"class"` // 0 确认 | 1 疑似 } -// AdminLabelImageItem 工作台单张图:全部标注(labels_json,AI 自动标注与人工框同层,人工可修改/清理) +// AdminLabelImageItem 工作台单张图:全部标注(labels_json,AI 预标与人工框同层,人工可修改/清理) type AdminLabelImageItem struct { - Filename string `json:"filename"` - Url string `json:"url"` - Width int `json:"width"` - Height int `json:"height"` - Boxes []*AdminLabelBox `json:"boxes"` - Labeled bool `json:"labeled"` // 有标注(AI 自动标注或人工保存) + Filename string `json:"filename"` + Url string `json:"url"` + Width int `json:"width"` + Height int `json:"height"` + Boxes []*AdminLabelBox `json:"boxes"` + Labeled bool `json:"labeled"` // 有标注框(AI 预标或人工保存) + ReviewStatus int `json:"reviewStatus"` // 审核三态 0=未标注,1=待审核,2=已审核 } type AdminLabelTaskDetailRes struct { diff --git a/server/biz/model/entity/annotate_record.go b/server/biz/model/entity/annotate_record.go new file mode 100644 index 0000000..d56b892 --- /dev/null +++ b/server/biz/model/entity/annotate_record.go @@ -0,0 +1,20 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +// AnnotateRecord 用户标注记录(服务端记录每用户每张图的处理明细,技术设计.md「App 标注众包与时长激励」): +// pending 即领取锁(partial unique index 同图仅一条 pending,超时惰性释放); +// UNIQUE(phone_num, image_id) 一人一图仅一次(拒后图回池可被他人再标); +// 拒绝不删记录——低质统计(通过比例)与审计依据。 +type AnnotateRecord struct { + Id int64 `json:"id" orm:"id" description:"自增主键"` + PhoneNum string `json:"phoneNum" orm:"phone_num" description:"用户手机号"` + TaskId int64 `json:"taskId" orm:"task_id" description:"所属任务"` + DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"数据集(领取时冗余,详情页按数据集过滤记录用)"` + ImageId int64 `json:"imageId" orm:"image_id" description:"图片"` + LabelsJson string `json:"labelsJson" orm:"labels_json" description:"提交快照(YOLO 归一化 JSON 数组)"` + Status string `json:"status" orm:"status" description:"pending|submitted|approved|rejected"` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"领取时间"` + SubmittedAt *gtime.Time `json:"submittedAt" orm:"submitted_at" description:"提交时间"` + ReviewedAt *gtime.Time `json:"reviewedAt" orm:"reviewed_at" description:"审核时间"` +} diff --git a/server/biz/model/entity/annotate_task.go b/server/biz/model/entity/annotate_task.go new file mode 100644 index 0000000..ef145f2 --- /dev/null +++ b/server/biz/model/entity/annotate_task.go @@ -0,0 +1,13 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +// AnnotateTask 标注众包任务(管理端下发):池子动态 = 数据集内 review_status=0 +// 且 clean_excluded=0 的图(不落表,实时查询),新增图自动入池,停用即整体不可领。 +type AnnotateTask struct { + Id int64 `json:"id" orm:"id" description:"自增主键"` + DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"数据集(任务池来源)"` + Name string `json:"name" orm:"name" description:"任务名"` + Status string `json:"status" orm:"status" description:"published|stopped"` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"下发时间"` +} diff --git a/server/biz/model/entity/dataset_image.go b/server/biz/model/entity/dataset_image.go index 47ac24a..1bb941b 100644 --- a/server/biz/model/entity/dataset_image.go +++ b/server/biz/model/entity/dataset_image.go @@ -5,12 +5,14 @@ import "github.com/gogf/gf/v2/os/gtime" // DatasetImage 数据集图片:文件在 datasets/<数据集名>/; // 标注为 JSON 数组存本表(元素同 dto.AdminLabelBox:class/cx/cy/w/h/confidence,YOLO 归一化)。 type DatasetImage struct { - Id int64 `json:"id" orm:"id" description:"自增主键"` - DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"` - Filename string `json:"filename" orm:"filename" description:"唯一文件名(防重名加时间戳后缀)"` - Source string `json:"source" orm:"source" description:"manual|ai"` - AnimalCount int `json:"animalCount" orm:"animal_count" description:"AI 生成提示词中的声明目标数量(0=手动上传,仅信息性,自动标注上限固定 3 与声明数无关)"` - LabelsJson string `json:"labelsJson" orm:"labels_json" description:"标注 JSON 数组(AI 自动标注与人工标注同存,null/''/'[]'=未标注)"` - CleanExcluded int `json:"cleanExcluded" orm:"clean_excluded" description:"数据清洗排除标记(0=正常,1=已从训练集排除;只影响训练集打包,图片/标注不动,可恢复)"` - CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"` + Id int64 `json:"id" orm:"id" description:"自增主键"` + DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"` + Filename string `json:"filename" orm:"filename" description:"唯一文件名(防重名加时间戳后缀)"` + Source string `json:"source" orm:"source" description:"manual|ai"` + AnimalCount int `json:"animalCount" orm:"animal_count" description:"AI 生成提示词中的声明目标数量(0=手动上传,仅信息性,自动标注上限固定 3 与声明数无关)"` + LabelsJson string `json:"labelsJson" orm:"labels_json" description:"标注 JSON 数组(AI 预标与人工标注同存,null/''/'[]'=无框)"` + ReviewStatus int `json:"reviewStatus" orm:"review_status" description:"审核三态(0=未标注,1=待审核,2=已审核;训练集只收2)"` + CleanExcluded int `json:"cleanExcluded" orm:"clean_excluded" description:"数据清洗排除标记(0=正常,1=已从训练集排除;只影响训练集打包,图片/标注不动,可恢复)"` + AnnotateTaskId int64 `json:"annotateTaskId" orm:"annotate_task_id" description:"众包任务占用标记(0=未下发;>0=已下发给该任务,从未标注tab隐藏,停用任务释放未领取图回0)"` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"` } diff --git a/server/biz/model/entity/license.go b/server/biz/model/entity/license.go index 6199a61..ec44442 100644 --- a/server/biz/model/entity/license.go +++ b/server/biz/model/entity/license.go @@ -5,10 +5,12 @@ import "github.com/gogf/gf/v2/os/gtime" // License 手机号账号与授权:一账号一记录;password 不对外(json 排除); // expires_at 未充值(含被撤销)时为 NULL;购买的套餐记录在 payment_order,本表不存。 type License struct { - PhoneNum string `json:"phoneNum" orm:"phone_num" description:"手机号账号(主键)"` - Password string `json:"-" orm:"password" description:"bcrypt 加盐哈希"` - ExpiresAt *gtime.Time `json:"expiresAt" orm:"expires_at" description:"到期时间(自然日,服务端时区),未充值 NULL"` - Remark string `json:"remark" orm:"remark" description:"管理端备注(客户端不可见)"` - CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"注册时间"` - UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"` + PhoneNum string `json:"phoneNum" orm:"phone_num" description:"手机号账号(主键)"` + Password string `json:"-" orm:"password" description:"bcrypt 加盐哈希"` + ExpiresAt *gtime.Time `json:"expiresAt" orm:"expires_at" description:"到期时间(自然日,服务端时区),未充值 NULL;标注奖励分钟级顺延"` + AnnotateFrozenUntil *gtime.Time `json:"-" orm:"annotate_frozen_until" description:"标注低质冻结到期标记(NULL/过期=正常,时间戳对比天然自动解冻)"` + AnnotateStatsSince *gtime.Time `json:"-" orm:"annotate_stats_since" description:"通过比例统计基线(冻结时重置,解冻后重新累计)"` + Remark string `json:"remark" orm:"remark" description:"管理端备注(客户端不可见)"` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"注册时间"` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"` } diff --git a/server/biz/model/entity/reward_log.go b/server/biz/model/entity/reward_log.go new file mode 100644 index 0000000..42f2ffe --- /dev/null +++ b/server/biz/model/entity/reward_log.go @@ -0,0 +1,12 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +// RewardLog 标注时长发放流水:日上限 = 自然日求和;只增不改(审计)。 +type RewardLog struct { + Id int64 `json:"id" orm:"id" description:"自增主键"` + PhoneNum string `json:"phoneNum" orm:"phone_num" description:"用户手机号"` + Minutes int `json:"minutes" orm:"minutes" description:"本次发放分钟数"` + TaskId int64 `json:"taskId" orm:"task_id" description:"触发发放的任务(0=未知)"` + GrantedAt *gtime.Time `json:"grantedAt" orm:"granted_at" description:"发放时间"` +} diff --git a/server/biz/service/annotate.go b/server/biz/service/annotate.go new file mode 100644 index 0000000..03e2a0e --- /dev/null +++ b/server/biz/service/annotate.go @@ -0,0 +1,724 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + "time" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + "observer-server/biz/consts" + "observer-server/biz/dao" + "observer-server/biz/model/dto" + "observer-server/biz/model/entity" + "observer-server/common" +) + +// annotateService 标注众包与时长激励(技术设计.md「App 标注众包与时长激励」): +// 管理端下发数据集级动态池任务 → App 用户领取(pending 锁)/提交(进待审核)→ +// 每累计 N 张即时发时长(日上限封顶)→ 管理端审核,通过比例过低自动冻结领取资格。 +type annotateService struct{} + +var Annotate = &annotateService{} + +// annotateRewardCfg annotateReward 配置(缺失/非法回退 consts 默认) +type annotateRewardCfg struct { + ClaimSize int + ClaimTimeoutHour int + RewardPerImages int + RewardMinutes int + DailyCapMinutes int + FreezeRatio float64 + FreezeMinReviewed int + FreezeHours int +} + +func annotateRewardConfig(ctx context.Context) annotateRewardCfg { + get := func(key string, def int) int { + v := g.Cfg().MustGet(ctx, "annotateReward."+key, def).Int() + if v <= 0 { + return def + } + return v + } + ratio := g.Cfg().MustGet(ctx, "annotateReward.freezeRatio", consts.AnnotateFreezeRatio).Float64() + if ratio <= 0 || ratio > 1 { + ratio = consts.AnnotateFreezeRatio + } + return annotateRewardCfg{ + ClaimSize: get("claimSize", consts.AnnotateClaimSize), + ClaimTimeoutHour: get("claimTimeoutHour", consts.AnnotateClaimTimeoutHour), + RewardPerImages: get("rewardPerImages", consts.AnnotateRewardPerImages), + RewardMinutes: get("rewardMinutes", consts.AnnotateRewardMinutes), + DailyCapMinutes: get("dailyCapMinutes", consts.AnnotateDailyCapMinutes), + FreezeRatio: ratio, + FreezeMinReviewed: get("freezeMinReviewed", consts.AnnotateFreezeMinReviewed), + FreezeHours: get("freezeHours", consts.AnnotateFreezeHours), + } +} + +// speciesOf 任务物种展示名:gen_species 空回退数据集名(单物种规则) +func annotateSpecies(d *entity.Dataset) string { + if d.GenSpecies != "" { + return d.GenSpecies + } + return d.Name +} + +// annotateImageUrl App 端图片访问地址(登录态 Bearer 鉴权,controller 直写响应体, +// 与管理端 /admin/datasets/image 的 X-Admin-Token 鉴权隔离——App 用户无管理 token) +func annotateImageUrl(ctx context.Context, datasetId int64, filename string) string { + return fmt.Sprintf("/api/v1/annotate/image?%s", + url.Values{"datasetId": {fmt.Sprintf("%d", datasetId)}, "filename": {filename}}.Encode()) +} + +func annotateFrozen(lic *entity.License) *gtime.Time { + if lic == nil || lic.AnnotateFrozenUntil == nil { + return nil + } + if lic.AnnotateFrozenUntil.Timestamp() <= gtime.Now().Timestamp() { + return nil // 已过期 = 自动解冻 + } + return lic.AnnotateFrozenUntil +} + +// ---------- 管理端 ---------- + +// AdminCreateTask 下发任务(2026-09-07 图片粒度):勾选的具体图片集合即任务图集—— +// dataset_image.annotate_task_id 写任务 id 作占用标记(已下发图即从管理端「未标注」tab 消失), +// 停用任务时释放未领取图回未标注。同数据集同时只允许一个 published 任务; +// Serial 内查重 + 逐图校验(属本数据集/未标注/未占用,任一非法整单拒绝)+ 事务内插任务与批量占用。 +func (s *annotateService) AdminCreateTask(ctx context.Context, req *dto.AdminAnnotateTaskCreateReq) (*dto.AdminAnnotateTaskCreateRes, error) { + dataset, err := dao.Dataset.GetById(ctx, req.DatasetId) + if err != nil { + return nil, err + } + if dataset == nil { + return nil, gerror.NewCode(common.CodeDatasetNotFound) + } + if dataset.Source == consts.DatasetSourceNegative { + return nil, gerror.New("负样本库不参与标注众包") + } + images, err := dao.DatasetImage.GetByIds(ctx, req.ImageIds) + if err != nil { + return nil, err + } + if len(images) != len(req.ImageIds) { + return nil, gerror.New("部分图片不存在,请刷新后重试") + } + for _, img := range images { + if img.DatasetId != dataset.Id { + return nil, gerror.Newf("图片 %d 不属于该数据集", img.Id) + } + if img.ReviewStatus != consts.ReviewImageNone { + return nil, gerror.Newf("图片 %d 已标注,只能下发未标注图", img.Id) + } + if img.AnnotateTaskId != 0 { + return nil, gerror.Newf("图片 %d 已下发给其他任务,请刷新后重试", img.Id) + } + } + var taskId int64 + err = common.Serial().Submit(ctx, func() error { + running, err := dao.AnnotateTask.GetPublishedByDataset(ctx, dataset.Id) + if err != nil { + return err + } + if running != nil { + return gerror.Newf("该数据集已有进行中的标注任务(id=%d),请先停用", running.Id) + } + return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + taskId, err = dao.AnnotateTask.InsertInTx(ctx, tx, &entity.AnnotateTask{ + DatasetId: dataset.Id, + Name: req.Name, + Status: consts.AnnotateTaskPublished, + CreatedAt: gtime.Now(), + }) + if err != nil { + return err + } + return dao.DatasetImage.OccupyByTask(ctx, req.ImageIds, taskId) + }) + }) + if err != nil { + return nil, err + } + return &dto.AdminAnnotateTaskCreateRes{Id: taskId}, nil +} + +// AdminListTasks 任务分页:组装数据集名/池余量/各状态记录数 +func (s *annotateService) AdminListTasks(ctx context.Context, req *dto.AdminAnnotateTaskListReq) (*dto.AdminAnnotateTaskListRes, error) { + page, size := common.NormalizePage(req.Page, req.Size) + list, total, err := dao.AnnotateTask.Page(ctx, req.DatasetId, page, size) + if err != nil { + return nil, err + } + res := &dto.AdminAnnotateTaskListRes{Total: total, List: make([]*dto.AdminAnnotateTaskItem, 0, len(list))} + if len(list) == 0 { + return res, nil + } + taskIds := make([]int64, 0, len(list)) + for _, t := range list { + taskIds = append(taskIds, t.Id) + } + names := Training.datasetNameMap(ctx) + poolCnt, err := dao.DatasetImage.CountPoolByTaskIds(ctx, taskIds) + if err != nil { + return nil, err + } + recCnt, err := dao.AnnotateRecord.CountByTaskIds(ctx, taskIds) + if err != nil { + return nil, err + } + for _, t := range list { + item := &dto.AdminAnnotateTaskItem{ + Id: t.Id, + DatasetId: t.DatasetId, + DatasetName: names[t.DatasetId], + Name: t.Name, + Status: t.Status, + PoolRemain: poolCnt[t.Id], + CreatedAt: t.CreatedAt, + } + if m := recCnt[t.Id]; m != nil { + item.Pending = m[consts.AnnotateRecordPending] + item.Submitted = m[consts.AnnotateRecordSubmitted] + item.Approved = m[consts.AnnotateRecordApproved] + item.Rejected = m[consts.AnnotateRecordRejected] + } + res.List = append(res.List, item) + } + return res, nil +} + +// AdminStopTask 停用任务(整体不可再领取;已领取未提交的可继续提交)。 +// Serial 内停用 + 释放:未被领取过的图清占用标记回未标注(重新出现在未标注 tab 可再次下发); +// 有 pending 锁的图保留占用(用户可继续提交)。过期锁先按领取同语义惰性清理,防已放弃的锁 +// 永久占图(停用任务不再有领取触发清理)。 +func (s *annotateService) AdminStopTask(ctx context.Context, req *dto.AdminAnnotateTaskStopReq) (*dto.AdminAnnotateTaskStopRes, error) { + t, err := dao.AnnotateTask.GetById(ctx, req.Id) + if err != nil { + return nil, err + } + if t == nil { + return nil, gerror.New("标注任务不存在") + } + cfg := annotateRewardConfig(ctx) + err = common.Serial().Submit(ctx, func() error { + if err := dao.AnnotateTask.Stop(ctx, req.Id); err != nil { + return err + } + if err := dao.AnnotateRecord.DeleteExpiredPending(ctx, gtime.Now().Add(-time.Duration(cfg.ClaimTimeoutHour)*time.Hour)); err != nil { + return err + } + pool, err := dao.DatasetImage.ListPoolByTask(ctx, req.Id) + if err != nil { + return err + } + lockedIds, err := dao.AnnotateRecord.ListPendingImageIdsByTask(ctx, req.Id) + if err != nil { + return err + } + locked := make(map[int64]bool, len(lockedIds)) + for _, id := range lockedIds { + locked[id] = true + } + release := make([]int64, 0, len(pool)) + for _, img := range pool { + if !locked[img.Id] { + release = append(release, img.Id) + } + } + return dao.DatasetImage.ReleaseByTask(ctx, release, req.Id) + }) + if err != nil { + return nil, err + } + return &dto.AdminAnnotateTaskStopRes{}, nil +} + +// AdminReview 批量审核:通过 → review_status=2;拒绝 → 清标注回未标注池。 +// 多表变更(dataset_image + annotate_record + license 冻结)走 Serial + 事务; +// 审核后对涉及用户重算通过比例,低于阈值自动冻结(解冻后按 stats_since 重新累计)。 +func (s *annotateService) AdminReview(ctx context.Context, req *dto.AdminAnnotateReviewReq) (*dto.AdminAnnotateReviewRes, error) { + res := &dto.AdminAnnotateReviewRes{FrozenPhones: []string{}} + // 只审待审核图(重复审核/未标注图直接忽略) + images, err := dao.DatasetImage.GetByIds(ctx, req.ImageIds) + if err != nil { + return nil, err + } + ids := make([]int64, 0, len(images)) + for _, img := range images { + if img.ReviewStatus == consts.ReviewImagePending { + ids = append(ids, img.Id) + } + } + if len(ids) == 0 { + return res, nil + } + now := gtime.Now() + recordStatus := consts.AnnotateRecordRejected + if req.Approve { + recordStatus = consts.AnnotateRecordApproved + } + var phones []string + err = common.Serial().Submit(ctx, func() error { + return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + if req.Approve { + if err := dao.DatasetImage.SetReviewStatus(ctx, ids, consts.ReviewImageApproved); err != nil { + return err + } + } else { + // 拒绝:清标注(不变式 0 ⟹ 无框)回未标注池,可被再次领取 + for _, id := range ids { + if err := dao.DatasetImage.UpdateLabelsAndReview(ctx, id, "", consts.ReviewImageNone); err != nil { + return err + } + } + } + if err := dao.AnnotateRecord.MarkReviewedByImages(ctx, ids, recordStatus, now); err != nil { + return err + } + phones, err = dao.AnnotateRecord.ListSubmittedPhonesByImages(ctx, ids) + return err + }) + }) + if err != nil { + return nil, err + } + res.Affected = int64(len(ids)) + // 审核后逐用户重算通过比例(IO 次数 = 涉及用户数,审核批量 ≤ 每页图片数,可控) + for _, phone := range phones { + frozen, err := s.maybeFreeze(ctx, phone) + if err != nil { + g.Log().Errorf(ctx, "标注低质冻结判定失败(%s): %+v", phone, err) + continue + } + if frozen { + res.FrozenPhones = append(res.FrozenPhones, phone) + } + } + return res, nil +} + +// maybeFreeze 通过比例过低自动冻结:已审核样本(stats_since 基线后)≥ FreezeMinReviewed 且 +// approved/(approved+rejected) < FreezeRatio → frozen_until = now + FreezeHours、基线重置(解冻后重新累计)。 +// 已到账时长不追溯扣回。返回是否触发冻结。 +func (s *annotateService) maybeFreeze(ctx context.Context, phone string) (bool, error) { + lic, err := dao.License.GetByPhone(ctx, phone) + if err != nil { + return false, err + } + if lic == nil { + return false, nil + } + stats, err := dao.AnnotateRecord.CountUserStats(ctx, phone, lic.AnnotateStatsSince) + if err != nil { + return false, err + } + reviewed := stats.Approved + stats.Rejected + if reviewed < int64(annotateRewardConfig(ctx).FreezeMinReviewed) { + return false, nil // 样本不足不判(防小样本误冻) + } + cfg := annotateRewardConfig(ctx) + if float64(stats.Approved)/float64(reviewed) >= cfg.FreezeRatio { + return false, nil + } + frozenUntil := gtime.Now().Add(time.Duration(cfg.FreezeHours) * time.Hour) + if err := dao.License.UpdateAnnotateState(ctx, phone, frozenUntil, gtime.Now()); err != nil { + return false, err + } + g.Log().Infof(ctx, "标注低质冻结: %s 通过 %d/%d,冻结至 %s", phone, stats.Approved, reviewed, frozenUntil) + return true, nil +} + +// AdminListRecords 用户标注记录分页(附图片文件/URL 与数据集名,内存组装) +func (s *annotateService) AdminListRecords(ctx context.Context, req *dto.AdminAnnotateRecordListReq) (*dto.AdminAnnotateRecordListRes, error) { + page, size := common.NormalizePage(req.Page, req.Size) + list, total, err := dao.AnnotateRecord.PageByFilter(ctx, req.Phone, req.TaskId, req.DatasetId, req.Status, page, size) + if err != nil { + return nil, err + } + res := &dto.AdminAnnotateRecordListRes{Total: total, List: make([]*dto.AdminAnnotateRecordItem, 0, len(list))} + if len(list) == 0 { + return res, nil + } + imageIds := make([]int64, 0, len(list)) + for _, r := range list { + imageIds = append(imageIds, r.ImageId) + } + images, err := dao.DatasetImage.GetByIds(ctx, imageIds) + if err != nil { + return nil, err + } + imageMap := make(map[int64]*entity.DatasetImage, len(images)) + datasetIds := make([]int64, 0) + for _, img := range images { + imageMap[img.Id] = img + datasetIds = append(datasetIds, img.DatasetId) + } + names := Training.datasetNameMap(ctx) + for _, r := range list { + item := &dto.AdminAnnotateRecordItem{ + Id: r.Id, + PhoneNum: r.PhoneNum, + TaskId: r.TaskId, + ImageId: r.ImageId, + Status: r.Status, + CreatedAt: r.CreatedAt, + SubmittedAt: r.SubmittedAt, + ReviewedAt: r.ReviewedAt, + } + if img := imageMap[r.ImageId]; img != nil { + item.Filename = img.Filename + item.DatasetName = names[img.DatasetId] + item.ImageUrl = datasetImageUrl(ctx, img.DatasetId, img.Filename) // 管理端页面展示:admin token 鉴权 + } + if r.LabelsJson != "" { + var boxes []*dto.AdminLabelBox + if json.Unmarshal([]byte(r.LabelsJson), &boxes) == nil { + item.Boxes = boxes + } + } + res.List = append(res.List, item) + } + return res, nil +} + +// AdminUnfreeze 手动解冻:清冻结标记与统计基线(历史重新累计) +func (s *annotateService) AdminUnfreeze(ctx context.Context, req *dto.AdminAnnotateUnfreezeReq) (*dto.AdminAnnotateUnfreezeRes, error) { + lic, err := dao.License.GetByPhone(ctx, req.Phone) + if err != nil { + return nil, err + } + if lic == nil { + return nil, gerror.New("账号不存在") + } + if err := dao.License.UpdateAnnotateState(ctx, req.Phone, nil, nil); err != nil { + return nil, err + } + return &dto.AdminAnnotateUnfreezeRes{}, nil +} + +// ---------- App ---------- + +// AppTaskList 可领任务列表 + 我的统计(冻结中仍展示,领取时拒绝) +func (s *annotateService) AppTaskList(ctx context.Context, req *dto.AnnotateTaskListReq) (*dto.AnnotateTaskListRes, error) { + phone := common.PhoneFromCtx(ctx) + tasks, err := dao.AnnotateTask.ListPublished(ctx) + if err != nil { + return nil, err + } + res := &dto.AnnotateTaskListRes{List: make([]*dto.AnnotateTaskItem, 0, len(tasks))} + stats, err := s.buildStats(ctx, phone) + if err != nil { + return nil, err + } + res.Stats = stats + if len(tasks) == 0 { + return res, nil + } + datasetIds := make([]int64, 0, len(tasks)) + taskIds := make([]int64, 0, len(tasks)) + for _, t := range tasks { + datasetIds = append(datasetIds, t.DatasetId) + taskIds = append(taskIds, t.Id) + } + datasets, err := dao.Dataset.GetByIds(ctx, datasetIds) + if err != nil { + return nil, err + } + datasetMap := make(map[int64]*entity.Dataset, len(datasets)) + for _, d := range datasets { + datasetMap[d.Id] = d + } + names := Training.datasetNameMap(ctx) + poolCnt, err := dao.DatasetImage.CountPoolByTaskIds(ctx, taskIds) + if err != nil { + return nil, err + } + for _, t := range tasks { + item := &dto.AnnotateTaskItem{ + Id: t.Id, + Name: t.Name, + DatasetId: t.DatasetId, + DatasetName: names[t.DatasetId], + PoolRemain: poolCnt[t.Id], + } + if d := datasetMap[t.DatasetId]; d != nil { + item.Species = annotateSpecies(d) + } + res.List = append(res.List, item) + } + return res, nil +} + +// AppClaim 领取:惰性释放过期锁 → 任务占用池内挑未处理过且未被锁定的图 → Serial+事务插入 pending 记录 +func (s *annotateService) AppClaim(ctx context.Context, req *dto.AnnotateClaimReq) (*dto.AnnotateClaimRes, error) { + phone := common.PhoneFromCtx(ctx) + cfg := annotateRewardConfig(ctx) + task, err := dao.AnnotateTask.GetById(ctx, req.TaskId) + if err != nil { + return nil, err + } + if task == nil { + return nil, gerror.New("标注任务不存在") + } + if task.Status != consts.AnnotateTaskPublished { + return nil, gerror.New("任务已停用") + } + lic, err := dao.License.GetByPhone(ctx, phone) + if err != nil { + return nil, err + } + if lic == nil { + return nil, gerror.New("账号不存在") + } + if annotateFrozen(lic) != nil { + return nil, gerror.Newf("标注资格已冻结(%s 解冻),暂不可领取任务", lic.AnnotateFrozenUntil) + } + dataset, err := dao.Dataset.GetById(ctx, task.DatasetId) + if err != nil { + return nil, err + } + if dataset == nil { + return nil, gerror.NewCode(common.CodeDatasetNotFound) + } + res := &dto.AnnotateClaimRes{Images: []*dto.AnnotateClaimImage{}, Species: annotateSpecies(dataset)} + // 惰性释放:超时未提交的领取锁直接删除(未产生任何标注,非处理记录) + if err := dao.AnnotateRecord.DeleteExpiredPending(ctx, gtime.Now().Add(-time.Duration(cfg.ClaimTimeoutHour)*time.Hour)); err != nil { + return nil, err + } + // 候选挑选放在 Serial 临界区内(挑图 + 插锁全程串行):两个用户并发领取时, + // 后者读到的 pending 锁一定包含前者刚插入的,同一张图不会分给多人—— + // partial unique index(image_id WHERE pending) 是跨实例兜底的最后一道防线 + now := gtime.Now() + var picked []*entity.DatasetImage + err = common.Serial().Submit(ctx, func() error { + pool, err := dao.DatasetImage.ListPoolByTask(ctx, task.Id) + if err != nil { + return err + } + mine, err := dao.AnnotateRecord.ListImageIdsByPhone(ctx, phone) + if err != nil { + return err + } + locked, err := dao.AnnotateRecord.ListPendingImageIds(ctx) + if err != nil { + return err + } + exclude := make(map[int64]bool, len(mine)+len(locked)) + for _, id := range mine { + exclude[id] = true + } + for _, id := range locked { + exclude[id] = true + } + picked = make([]*entity.DatasetImage, 0, cfg.ClaimSize) + for _, img := range pool { + if len(picked) >= cfg.ClaimSize { + break + } + if exclude[img.Id] { + continue + } + picked = append(picked, img) + } + if len(picked) == 0 { + return nil + } + return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + for _, img := range picked { + if err := dao.AnnotateRecord.InsertInTx(ctx, tx, &entity.AnnotateRecord{ + PhoneNum: phone, + TaskId: task.Id, + DatasetId: task.DatasetId, + ImageId: img.Id, + Status: consts.AnnotateRecordPending, + CreatedAt: now, + }); err != nil { + return err + } + } + return nil + }) + }) + if err != nil { + return nil, err + } + if len(picked) == 0 { + return res, nil + } + dir := common.DatasetImagesDir(ctx, dataset.Name) + for _, img := range picked { + item := &dto.AnnotateClaimImage{ + ImageId: img.Id, + Url: annotateImageUrl(ctx, dataset.Id, img.Filename), + } + if data, rErr := os.ReadFile(filepath.Join(dir, img.Filename)); rErr == nil { + item.Width, item.Height = imageSize(data) + } + res.Images = append(res.Images, item) + } + return res, nil +} + +// AppSubmit 提交标注:写 labels_json + 待审核;每累计 RewardPerImages 张有效提交即时发时长 +// (expires_at = max(now, expires_at) + RewardMinutes 分钟级顺延;当日累计超上限不再发) +func (s *annotateService) AppSubmit(ctx context.Context, req *dto.AnnotateSubmitReq) (*dto.AnnotateSubmitRes, error) { + phone := common.PhoneFromCtx(ctx) + cfg := annotateRewardConfig(ctx) + for _, box := range req.Boxes { + if box.Cx < 0 || box.Cy < 0 || box.W <= 0 || box.H <= 0 || box.Cx > 1 || box.Cy > 1 { + return nil, gerror.New("标注框坐标非法(需 0~1 归一化)") + } + } + raw, err := json.Marshal(req.Boxes) + if err != nil { + return nil, gerror.Wrap(err, "标注序列化失败") + } + record, err := dao.AnnotateRecord.GetMyPending(ctx, phone, req.ImageId) + if err != nil { + return nil, err + } + if record == nil { + return nil, gerror.New("未领取该图片或已提交") + } + img, err := dao.DatasetImage.GetById(ctx, req.ImageId) + if err != nil { + return nil, err + } + if img == nil { + return nil, gerror.NewCode(common.CodeImageNotFound) + } + now := gtime.Now() + // Serial 内先提交(提交计数与发放判定串行化,防并发双发) + err = common.Serial().Submit(ctx, func() error { + return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + if err := dao.AnnotateRecord.MarkSubmitted(ctx, record.Id, string(raw), now); err != nil { + return err + } + // App 提交一律进待审核(空框=「画面无目标」的主张,同样待审核确认) + if err := dao.DatasetImage.UpdateLabelsAndReview(ctx, img.Id, string(raw), consts.ReviewImagePending); err != nil { + return err + } + // 奖励判定:累计提交数整跨 RewardPerImages → 尝试发放 + stats, err := dao.AnnotateRecord.CountUserStats(ctx, phone, nil) + if err != nil { + return err + } + if stats.SubmittedTotal%int64(cfg.RewardPerImages) != 0 { + return nil + } + dayStart := gtime.New(now.Format("Y-m-d") + " 00:00:00") + todaySum, err := dao.RewardLog.SumMinutesByPhoneSince(ctx, phone, dayStart) + if err != nil { + return err + } + if todaySum+cfg.RewardMinutes > cfg.DailyCapMinutes { + g.Log().Infof(ctx, "标注奖励触达日上限不发: %s 今日已得 %d 分钟", phone, todaySum) + return nil + } + lic, err := dao.License.GetByPhoneInTx(ctx, tx, phone) + if err != nil { + return err + } + if lic == nil { + return gerror.New("账号不存在") + } + base := now + if lic.ExpiresAt != nil && lic.ExpiresAt.Timestamp() > now.Timestamp() { + base = lic.ExpiresAt + } + newExpires := base.Add(time.Duration(cfg.RewardMinutes) * time.Minute) + if err := dao.License.ExtendExpiresInTx(ctx, tx, phone, newExpires); err != nil { + return err + } + if err := dao.RewardLog.InsertInTx(ctx, tx, phone, cfg.RewardMinutes, record.TaskId, now); err != nil { + return err + } + common.ClearCache(ctx, "license:"+phone) + g.Log().Infof(ctx, "标注奖励发放: %s +%d 分钟,到期 %s", phone, cfg.RewardMinutes, newExpires) + return nil + }) + }) + if err != nil { + return nil, err + } + // 回程统计(展示口径,允许轻微滞后) + dayStart := gtime.New(now.Format("Y-m-d") + " 00:00:00") + todaySum, _ := dao.RewardLog.SumMinutesByPhoneSince(ctx, phone, dayStart) + lic, _ := dao.License.GetByPhone(ctx, phone) + stats, _ := dao.AnnotateRecord.CountUserStats(ctx, phone, nil) + var expiresAt *gtime.Time + if lic != nil { + expiresAt = lic.ExpiresAt + } + return &dto.AnnotateSubmitRes{ + Granted: stats.SubmittedTotal > 0 && stats.SubmittedTotal%int64(cfg.RewardPerImages) == 0, + Minutes: cfg.RewardMinutes, + TodayEarnedMinutes: todaySum, + ExpiresAt: expiresAt, + ProgressDone: stats.SubmittedTotal % int64(cfg.RewardPerImages), + }, nil +} + +// AppMe 我的标注统计 +func (s *annotateService) AppMe(ctx context.Context, req *dto.AnnotateMeReq) (*dto.AnnotateMeRes, error) { + stats, err := s.buildStats(ctx, common.PhoneFromCtx(ctx)) + if err != nil { + return nil, err + } + return &dto.AnnotateMeRes{Stats: stats}, nil +} + +// buildStats 我的统计组装(任务列表/我的页/提交回程共用) +func (s *annotateService) buildStats(ctx context.Context, phone string) (*dto.AnnotateStats, error) { + cfg := annotateRewardConfig(ctx) + stats := &dto.AnnotateStats{ + RewardPerImages: cfg.RewardPerImages, + RewardMinutes: cfg.RewardMinutes, + DailyCapMinutes: cfg.DailyCapMinutes, + ApproveRatio: 1, // 无样本不惩罚 + ProgressDone: 0, + } + lic, err := dao.License.GetByPhone(ctx, phone) + if err != nil { + return nil, err + } + var since *gtime.Time + if lic != nil { + since = lic.AnnotateStatsSince + stats.FrozenUntil = annotateFrozen(lic) + stats.ExpiresAt = lic.ExpiresAt + } + rec, err := dao.AnnotateRecord.CountUserStats(ctx, phone, since) + if err != nil { + return nil, err + } + stats.SubmittedTotal = rec.SubmittedTotal + stats.Approved = rec.Approved + stats.Rejected = rec.Rejected + if reviewed := rec.Approved + rec.Rejected; reviewed > 0 { + stats.ApproveRatio = float64(rec.Approved) / float64(reviewed) + } + stats.ProgressDone = rec.SubmittedTotal % int64(cfg.RewardPerImages) + total, err := dao.RewardLog.SumMinutesByPhone(ctx, phone) + if err != nil { + return nil, err + } + stats.TotalEarnedMinutes = total + dayStart := gtime.New(gtime.Now().Format("Y-m-d") + " 00:00:00") + today, err := dao.RewardLog.SumMinutesByPhoneSince(ctx, phone, dayStart) + if err != nil { + return nil, err + } + stats.TodayEarnedMinutes = today + return stats, nil +} diff --git a/server/biz/service/dataset.go b/server/biz/service/dataset.go index b06c342..f3393be 100644 --- a/server/biz/service/dataset.go +++ b/server/biz/service/dataset.go @@ -499,6 +499,9 @@ func (s *datasetService) CompressExistingCovers(ctx context.Context) error { // AdminCreateDataset 新建数据集:名称唯一(UNIQUE 兜底)+ 创建图片目录 + // VLM 同步生成生成参数池(失败不阻断创建,poolsGenerated/poolError 反馈)。 func (s *datasetService) AdminCreateDataset(ctx context.Context, req *dto.AdminDatasetCreateReq) (*dto.AdminDatasetCreateRes, error) { + if req.Name == consts.NegativeDatasetName { + return nil, gerror.New("该名称为负样本库保留名,请更换数据集名称") + } now := gtime.Now() var id int64 err := common.Serial().Submit(ctx, func() error { @@ -571,6 +574,47 @@ func (s *datasetService) AdminCreateDataset(ctx context.Context, req *dto.AdminD return res, nil } +// AdminNegativeLibrary 获取负样本库(技术设计.md「负样本库」):source=negative 的固定保留名 +// 特殊数据集,不存在则 Serial 内懒创建(防并发重建)+ 建目录;返回 id 与图片数, +// 管理端「负样本」tab 复用既有上传/图片列表/删除接口(负样本无标注/审核/清洗/生成流程) +func (s *datasetService) AdminNegativeLibrary(ctx context.Context, req *dto.AdminDatasetNegativeReq) (*dto.AdminDatasetNegativeRes, error) { + now := gtime.Now() + var lib *entity.Dataset + err := common.Serial().Submit(ctx, func() error { + existing, err := dao.Dataset.GetByName(ctx, consts.NegativeDatasetName) + if err != nil { + return err + } + if existing != nil { + lib = existing + return nil + } + id, err := dao.Dataset.Insert(ctx, &entity.Dataset{ + Name: consts.NegativeDatasetName, + Source: consts.DatasetSourceNegative, + Status: consts.DatasetStatusBuilding, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + return err + } + lib, err = dao.Dataset.GetById(ctx, id) + return err + }) + if err != nil { + return nil, err + } + if err := os.MkdirAll(common.DatasetImagesDir(ctx, lib.Name), 0o755); err != nil { + return nil, gerror.Wrap(err, "创建负样本库目录失败") + } + cnt, err := dao.DatasetImage.CountByDataset(ctx, lib.Id) + if err != nil { + return nil, err + } + return &dto.AdminDatasetNegativeRes{Id: lib.Id, Name: lib.Name, ImageCount: cnt}, nil +} + // ensureJpeg 模型生成的图片统一转 jpg:解码校验 + 非 jpeg(png/webp 等)转 jpeg(Quality 92)。 // 生成服务返回格式不可控,扩展名统一 .jpg,内容须与扩展名一致(RF-DETR 按扩展名推断 mime 提交)。 func ensureJpeg(data []byte) ([]byte, error) { @@ -819,6 +863,9 @@ func (s *datasetService) AdminDeleteDataset(ctx context.Context, req *dto.AdminD if d == nil { return gerror.NewCode(common.CodeDatasetNotFound) } + if d.Source == consts.DatasetSourceNegative { + return gerror.New("负样本库不可删除整库(如需清理请逐张删除图片)") + } name = d.Name modelName = modelFileName(d.Name, d.NamePrefix) // 预标注任务进行中(RF-DETR 正在扫该数据集图片) @@ -904,13 +951,7 @@ func (s *datasetService) AdminUploadImages(ctx context.Context, req *dto.AdminDa if len(saved) == 0 { return res, nil } - // 标注强语义:未配置标注服务时不允许产生无标注图(文件已落盘,失败则清掉) - if common.LocalAiClient(ctx) == nil { - for _, name := range saved { - _ = os.Remove(filepath.Join(dir, name)) - } - return nil, gerror.NewCode(common.CodeLocalAiNotConfigured) - } + // 标注不再强制(2026-09-04 自动标注退场):未配置 localAi 也可上传,图片以未标注态入池 addedIds := make([]int64, 0, len(saved)) err = common.Serial().Submit(ctx, func() error { for _, name := range saved { @@ -943,30 +984,7 @@ func (s *datasetService) AdminUploadImages(ctx context.Context, req *dto.AdminDa } return nil, err } - // 自动触发标注:忙(已有 running 任务)不报错,由任务完成后的自动补标轮兜底;其他失败回滚本次入库 - newImages, err := dao.DatasetImage.GetByIds(ctx, addedIds) - if err != nil { - return nil, err - } - if err := LabelTask.AutoLabel(ctx, dataset, newImages); err != nil { - // 触发失败回滚本次入库(manual 上传文件非付费资产,可删) - rollbackIds := make([]int64, 0, len(newImages)) - for _, img := range newImages { - rollbackIds = append(rollbackIds, img.Id) - if rErr := os.Remove(filepath.Join(dir, img.Filename)); rErr != nil { - g.Log().Warningf(ctx, "回滚删除图片文件失败: %s: %v", img.Filename, rErr) - } - } - if rErr := common.Serial().Submit(ctx, func() error { - if dErr := dao.DatasetImage.DeleteByIds(ctx, rollbackIds); dErr != nil { - return dErr - } - return dao.Dataset.UpdateCounters(ctx, dataset.Id, -int64(len(rollbackIds)), 0, "") - }); rErr != nil { - g.Log().Errorf(ctx, "自动标注触发失败后的入库回滚失败: %v", rErr) - } - return nil, err - } + // 上传不触发标注(2026-09-04 自动标注退场):图片以未标注态入池(管理端预标/众包任务池) return res, nil } @@ -1244,10 +1262,10 @@ func (s *datasetService) AdminGenerateImages(ctx context.Context, req *dto.Admin if dataset == nil { return nil, gerror.NewCode(common.CodeDatasetNotFound) } - // 标注强语义:未配置标注服务时不允许开始生成(避免付费资产生成后无法标注) - if common.LocalAiClient(ctx) == nil { - return nil, gerror.NewCode(common.CodeLocalAiNotConfigured) + if dataset.Source == consts.DatasetSourceNegative { + return nil, gerror.New("负样本库不支持 AI 生成图片") } + // 标注不再强制(2026-09-04 自动标注退场):未配置 localAi 也可生成,图以未标注态入池 dir := common.DatasetImagesDir(ctx, dataset.Name) if err := os.MkdirAll(dir, 0o755); err != nil { return nil, gerror.Wrap(err, "创建图片目录失败") @@ -1386,7 +1404,6 @@ func (s *datasetService) runGenTask(taskId int64, dataset *entity.Dataset, req * }) } if failed != "" { - s.genTaskAutoLabel(bgCtx, dataset, addedIds) // 部分失败:已入库图按付费资产保留,计数须计入(曾漏更新致 image_count 漂移为负) if err := common.Serial().Submit(bgCtx, func() error { return dao.Dataset.UpdateCounters(bgCtx, dataset.Id, int64(generated), -1, "") @@ -1396,33 +1413,16 @@ func (s *datasetService) runGenTask(taskId int64, dataset *entity.Dataset, req * _ = dao.GenTask.Finish(bgCtx, taskId, failed) return } - // 全部成功:更新数据集计数 → 自动标注 → 置 done + // 全部成功:更新数据集计数 → 置 done(图以未标注态入池,2026-09-04 自动标注退场) if err := common.Serial().Submit(bgCtx, func() error { return dao.Dataset.UpdateCounters(bgCtx, dataset.Id, int64(generated), -1, "") }); err != nil { g.Log().Errorf(bgCtx, "生成任务 %d 更新数据集计数失败: %+v", taskId, err) } - s.genTaskAutoLabel(bgCtx, dataset, addedIds) _ = dao.GenTask.Finish(bgCtx, taskId, "") }() } -// genTaskAutoLabel 生成任务完成后对本次新增图触发自动标注:忙(已有 running 任务)不报错, -// 由标注任务完成后的自动补标轮兜底;其他失败只记日志(付费资产,不可删)。 -func (s *datasetService) genTaskAutoLabel(ctx context.Context, dataset *entity.Dataset, addedIds []int64) { - if len(addedIds) == 0 { - return - } - newImages, err := dao.DatasetImage.GetByIds(ctx, addedIds) - if err != nil { - g.Log().Errorf(ctx, "生成任务后读取新增图失败: %+v", err) - return - } - if err := LabelTask.AutoLabel(ctx, dataset, newImages); err != nil { - g.Log().Errorf(ctx, "生成任务后自动标注触发失败: %+v", err) - } -} - // AdminGenTaskQuery 生成任务进度(最近一次;无任务返回 nil) func (s *datasetService) AdminGenTaskQuery(ctx context.Context, req *dto.AdminGenTaskQueryReq) (*dto.AdminGenTaskQueryRes, error) { dataset, err := dao.Dataset.GetById(ctx, req.DatasetId) @@ -1467,12 +1467,13 @@ func (s *datasetService) AdminListImages(ctx context.Context, req *dto.AdminData items := make([]*dto.AdminImageItem, 0, len(list)) for _, v := range list { items = append(items, &dto.AdminImageItem{ - Id: v.Id, - Filename: v.Filename, - Source: v.Source, - Url: datasetImageUrl(ctx, dataset.Id, v.Filename), - CleanExcluded: v.CleanExcluded, - CreatedAt: v.CreatedAt, + Id: v.Id, + Filename: v.Filename, + Source: v.Source, + Url: datasetImageUrl(ctx, dataset.Id, v.Filename), + CleanExcluded: v.CleanExcluded, + AnnotateTaskId: v.AnnotateTaskId, + CreatedAt: v.CreatedAt, }) } return &dto.AdminDatasetImagesRes{List: items}, nil diff --git a/server/biz/service/label_task.go b/server/biz/service/label_task.go index 3f4ab03..5358486 100644 --- a/server/biz/service/label_task.go +++ b/server/biz/service/label_task.go @@ -87,6 +87,9 @@ func (s *labelTaskService) AdminStartLabelTask(ctx context.Context, req *dto.Adm if dataset == nil { return nil, gerror.NewCode(common.CodeDatasetNotFound) } + if dataset.Source == consts.DatasetSourceNegative { + return nil, gerror.New("负样本库图片无需标注(训练打包时以空标签作为背景参与)") + } if common.LocalAiClient(ctx) == nil { return nil, gerror.NewCode(common.CodeLocalAiNotConfigured) } @@ -158,46 +161,9 @@ func (s *labelTaskService) startDetection(ctx context.Context, dataset *entity.D return taskId, nil } -// AutoLabel 图片入库自动触发标注(上传/AI 生成成功后调用):只标本次新增图。 -// localAi 未配置 → 报错(调用方拒绝/回滚,不允许产生无标注图); -// 已有 running 任务(忙)→ 不报错:本次跳过,由任务成功完成后的自动补标轮兜底。 -func (s *labelTaskService) AutoLabel(ctx context.Context, dataset *entity.Dataset, images []*entity.DatasetImage) error { - if len(images) == 0 { - return nil - } - if common.LocalAiClient(ctx) == nil { - return gerror.NewCode(common.CodeLocalAiNotConfigured) - } - names := make([]string, 0, len(images)) - for _, img := range images { - names = append(names, img.Filename) - } - filenamesJSON := "" - if raw, jErr := json.Marshal(names); jErr == nil { - filenamesJSON = string(raw) - } - _, err := s.startDetection(ctx, dataset, images, filenamesJSON) - if err != nil && gerror.HasCode(err, common.CodeLabelTaskRunning) { - return nil // 忙:不报错 - } - return err -} - -// autoSupplement 自动补标:任务成功完成后,该数据集仍有从未扫描过的图(忙时入库等)则续一轮 -// 只扫未扫描图;失败任务不续(防配置故障时无限重试)。 -// 池语义 = labels 为 null/”(见 ListUnlabeledByDataset),已扫过的图(含空检出写 '[]')不入池—— -// 空检出图若留池中,每轮完成续一轮只会轮到别的空图、轮完又轮回来(2026-09-02 兔子 3 张空检出 -// 实测 {9}→{10,17}→{9}… 乒乓死循环,RNPHE_202 单张案例侥幸被上一版「排除本轮图」挡住); -// 空检出图重扫结果不变,确需重扫由人工触发(AdminStartLabelTask 指定文件名/全量)。 -func (s *labelTaskService) autoSupplement(ctx context.Context, dataset *entity.Dataset) { - unlabeled, err := dao.DatasetImage.ListUnlabeledByDataset(ctx, dataset.Id) - if err != nil || len(unlabeled) == 0 { - return - } - if err := s.AutoLabel(ctx, dataset, unlabeled); err != nil { - g.Log().Infof(ctx, "自动补标未触发(数据集 %d): %v", dataset.Id, err) - } -} +// AutoLabel 已随自动标注退场删除(2026-09-04 用户定案):上传/生成入库不再触发标注, +// 标注唯一入口 = 管理端勾选图片「预标」(AdminStartLabelTask); +// 原 autoSupplement 自动补标机制一并删除(空检出乒乓问题随机制消亡)。 // runDetection 预标注执行协程(生命周期任务):池内逐张检测,进度经 Serial 更新。 // 每张:读图 → 全图检测(等比缩放提交,坐标映射回原图归一化)→ 标注(conf≥confConfirmed 为 class 0)。 @@ -272,14 +238,19 @@ func (s *labelTaskService) runDetection(ctx context.Context, taskId int64, datas _ = dao.LabelTask.Finish(bgCtx, taskId, failed) return } - // 全部成功:Serial 内逐张覆写标注(重跑覆盖该图标注,人工保存同走 UpdateLabels) + // 全部成功:Serial 内逐张覆写标注(重跑覆盖该图标注); + // 有检出框 → 待审核(1),空检出 → 未标注(0)(无框可审,等人工画框) err := common.Serial().Submit(bgCtx, func() error { for i := range results { raw, jErr := json.Marshal(results[i]) if jErr != nil { return gerror.Wrap(jErr, "标注序列化失败") } - if uErr := dao.DatasetImage.UpdateLabels(bgCtx, images[i].Id, string(raw)); uErr != nil { + status := consts.ReviewImageNone + if len(results[i]) > 0 { + status = consts.ReviewImagePending + } + if uErr := dao.DatasetImage.UpdateLabelsAndReview(bgCtx, images[i].Id, string(raw), status); uErr != nil { return uErr } } @@ -290,9 +261,6 @@ func (s *labelTaskService) runDetection(ctx context.Context, taskId int64, datas return } _ = dao.LabelTask.Finish(bgCtx, taskId, "") - // 成功完成:自动补标该数据集从未扫描过的图(忙时入库的图等兜底),失败任务不续; - // 空检出 '[]' 图不入池(见 ListUnlabeledByDataset),每张图同链条最多被自动扫一次 - s.autoSupplement(bgCtx, dataset) }() } @@ -579,6 +547,7 @@ func (s *labelTaskService) buildWorkbenchItems(ctx context.Context, dataset *ent item.Labeled = true } } + item.ReviewStatus = img.ReviewStatus if data, rErr := os.ReadFile(filepath.Join(imgDir, img.Filename)); rErr == nil { item.Width, item.Height = imageSize(data) } @@ -630,18 +599,20 @@ func (s *labelTaskService) AdminLabelSave(ctx context.Context, req *dto.AdminLab return nil, gerror.New("标注框坐标非法(需 0~1 归一化)") } } - // 空框 = 清空标注 + // 空框 = 清空标注;人工保存即审核定稿(有框→2 已审核,空框→0 未标注回池) raw := "" + reviewStatus := consts.ReviewImageNone if len(req.Boxes) > 0 { b, err := json.Marshal(req.Boxes) if err != nil { return nil, gerror.Wrap(err, "标注序列化失败") } raw = string(b) + reviewStatus = consts.ReviewImageApproved } var labeled int64 if err := common.Serial().Submit(ctx, func() error { - if err := dao.DatasetImage.UpdateLabels(ctx, img.Id, raw); err != nil { + if err := dao.DatasetImage.UpdateLabelsAndReview(ctx, img.Id, raw, reviewStatus); err != nil { return err } labeled, err = dao.DatasetImage.CountLabeledByDataset(ctx, dataset.Id) @@ -677,8 +648,11 @@ func (s *labelTaskService) prepareYoloSet(ctx context.Context, dataset *entity.D if img.CleanExcluded == 1 { continue // 数据清洗排除图不进训练集(图片/标注保留,可恢复) } + if img.ReviewStatus != consts.ReviewImageApproved { + continue // 训练集只收人工审核通过的图(标签质量闸门,2026-09-04 审核状态机) + } if img.LabelsJson == "" || img.LabelsJson == "[]" { - continue // 空标注图剔除 + continue // 空框(确认无目标)不进训练集:维持原负样本不入包行为 } var boxes []*dto.AdminLabelBox if json.Unmarshal([]byte(img.LabelsJson), &boxes) != nil || len(boxes) == 0 { @@ -723,9 +697,54 @@ func (s *labelTaskService) prepareYoloSet(ctx context.Context, dataset *entity.D } addSplit("train", trainItems) addSplit("val", valItems) + // 混入负样本库(技术设计.md「负样本库」):全部物种数据集共用一套统一背景样本, + // 库不存在/为空时行为与无负样本完全一致 + if err := s.appendNegatives(ctx, pkg); err != nil { + return nil, err + } return pkg, nil } +// appendNegatives 训练包混入负样本库图片:空标签 txt = 背景图(ultralytics 自动按背景学习, +// 无需改训练脚本),文件名加 neg_ 前缀防与主数据集图重名覆盖;负样本无审核语义 +// (不看 review_status/clean_excluded),同一 80/20 逻辑拆 train/val(val 含负样本 +// 可在训练指标中观测背景误检);label txt 写空内容。 +func (s *labelTaskService) appendNegatives(ctx context.Context, pkg *common.YoloPackage) error { + lib, err := dao.Dataset.GetByName(ctx, consts.NegativeDatasetName) + if err != nil { + return err + } + if lib == nil { + return nil + } + images, err := dao.DatasetImage.ListByDataset(ctx, lib.Id) + if err != nil { + return err + } + if len(images) == 0 { + return nil + } + idx := make([]int, len(images)) + for i := range idx { + idx[i] = i + } + randShuffle(idx) + nVal := len(idx) / 5 + srcDir := common.DatasetImagesDir(ctx, lib.Name) + for i, j := range idx { + split := "train" + if i < nVal { + split = "val" + } + name := "neg_" + images[j].Filename + pkg.Files = append(pkg.Files, + common.YoloFile{Name: filepath.Join("images", split, name), ImagePath: filepath.Join(srcDir, images[j].Filename)}, + common.YoloFile{Name: filepath.Join("labels", split, strings.TrimSuffix(name, filepath.Ext(name))+".txt"), Content: []byte{}}, + ) + } + return nil +} + // randShuffle Fisher-Yates 伪随机(固定种子,沿用原 prepare_yolo.py random.seed(42) 语义) func randShuffle(n []int) { state := uint32(42) diff --git a/server/biz/service/training.go b/server/biz/service/training.go index 17dab5b..5994a7a 100644 --- a/server/biz/service/training.go +++ b/server/biz/service/training.go @@ -387,6 +387,9 @@ func (s *trainingService) AdminStartTraining(ctx context.Context, req *dto.Admin if dataset == nil { return nil, gerror.NewCode(common.CodeDatasetNotFound) } + if dataset.Source == consts.DatasetSourceNegative { + return nil, gerror.New("负样本库不能单独训练(打包时自动混入各数据集)") + } variants, err := normalizeVariants(req.Variants, cfg) if err != nil { return nil, err 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/config.yml b/server/config.yml index 5d1b194..a3497ce 100644 --- a/server/config.yml +++ b/server/config.yml @@ -110,6 +110,17 @@ labelTask: # 预标注并发度(逐张调 RF-DETR,缺失或非法时回退默认值) poolSize: 4 +annotateReward: + # 标注众包时长激励(技术设计.md「App 标注众包与时长激励」;缺失/非法回退 consts 默认值) + claimSize: 10 # 每次领取图片数 + claimTimeoutHour: 2 # 领取锁超时(pending 超时未提交视为放弃,领取时惰性释放) + rewardPerImages: 10 # 每累计提交 N 张发一次时长 + rewardMinutes: 30 # 每次发放分钟数(license 到期时间分钟级顺延) + dailyCapMinutes: 120 # 每日发放上限(自然日) + freezeRatio: 0.8 # 通过比例(approved/(approved+rejected))低于该值触发冻结 + freezeMinReviewed: 5 # 触发冻结判定的最小已审核样本数(防小样本误冻) + freezeHours: 24 # 冻结时长(license.annotate_frozen_until 时间戳对比自动解冻,基线重置后统计重新累计) + # 套餐定价(静态配置,改价 = 改本节点后重启服务;金额单位为分,展示名由 days 派生"N天") plans: - id: day diff --git a/server/data/observer.db b/server/data/observer.db index 4a657f0..61ad51c 100644 Binary files a/server/data/observer.db and b/server/data/observer.db differ diff --git a/server/main.go b/server/main.go index 1841faf..6d3b004 100644 --- a/server/main.go +++ b/server/main.go @@ -41,7 +41,7 @@ func main() { }) s.Group("/api/v1", func(group *ghttp.RouterGroup) { group.Middleware(common.UnifiedResponse, common.AuthRequired) - common.BindController(group, controller.Order, controller.License, controller.ModelCatalog) + common.BindController(group, controller.Order, controller.License, controller.ModelCatalog, controller.Annotate) }) // 支付回调组:不做统一包装,按渠道应答格式直接返回(微信 SUCCESS/FAIL JSON、支付宝 success/failure 文本) s.Group("/api/v1/payment", func(group *ghttp.RouterGroup) { diff --git a/server/技术设计.md b/server/技术设计.md index d88fc35..1b22986 100644 --- a/server/技术设计.md +++ b/server/技术设计.md @@ -407,8 +407,11 @@ training: - `localAi` 配置节点:键与取值以 config.yml 为准,文档不复制具体数值(曾多次调整阈值致双源漂移,2026-09-02 起删除此类描述)——**部署前提:RF-DETR 服务须从 Go 服务器可达**(现跑在 Mac 上,部署时搬服务器/训练机;**未配置时图片上传/生成接口直接报错**——标注是强语义,不允许产生无标注图片;配置调不通则任务 failed,页顶错误条展示,不自动重试);**端点为配置唯一来源,无运行时覆盖** - **预标注自动触发(2026-08-26 决策,无手动按钮)**:手动上传/AI 生成图片入库成功后,自动对**本次新增图**发起标注;**`localAi` 未配置 → 上传/生成接口直接报错**(同步检查,生成场景在第一张生成前检查避免付费资产生成后标不了);**已有 running 标注任务(忙)→ 不报错**:上传/生成照常成功,新图由「任务成功完成后自动补标未标注图」机制兜底(每轮任务成功结束时检查该数据集**从未扫描过**的图(labels 为 null/''),有则自动续一轮——失败任务不续,防配置坏时无限重试;**2026-09-02 空检出图不入自动补标池**:检出为空的图写 `labels_json='[]'` 后仍被视作未标注(管理端未标注 tab 照常展示等人工画框),但不入自动池——空检出图若留池,每轮自动续一轮只会扫到别的空图、扫完又轮回来:RNPHE_202(阈值调高后 0 检出,低阈下 12 个候选全为噪声,无一过丢弃线)单张被上一版「排除本轮已扫图」恰好挡住未暴露,2026-09-02 兔子批量 3 张空检出实测 {9}→{10,17}→{9}… 无限交替、每轮白打一次 RF-DETR 方暴露;现自动链条只扫从未扫描的图,每张图至多被自动扫一次,空检出图确需重扫由人工触发(按文件名批量/全量任务,见下));service 逐张调 local-ai 推理(common 池并行,`label_task` 记录 total/done 进度)→ 扫描结果(YOLO 归一化 xywh + 置信度 + 建议类别)**直写 `dataset_image.labels_json`(重跑覆盖该图标注)**;**置信度分档(2026-09-02 起按两档语义调定,具体数值以 config.yml 为准不在此复制)**:conf ≥ confConfirmed 落 class 0 真目标,threshold ≤ conf < confConfirmed 落 class 1 疑似待人工复核,低于 threshold 不入库;背景(RNPHE_257 实测):COCO 通用 rfdetr 对类鸟纹理误检置信度可达 0.46~0.5 且与真目标仅差 ~0.1(真目标 0.562、两误检 0.477/0.462 曾全落 class 0),低置信段是纯噪声候选(该图 21 个原始检出中 17 个落此区间);单阈值无法分离真/假目标(类别名也无用——全部输出 "bird");误检落 class 0 会作为雉鸡正样本直接污染训练集,危害大于真目标降级疑似(后者人工一键确认即可);**重叠去重(2026-08-26 修订)**:NMS 风格、按置信度降序依次保留,与已保留框**重叠比(minIoU = 交叠面积/两框较小面积)> `localAi.overlapThreshold`**的框剔除(同目标被重复检出只留置信度最高者,跨 class 去重;人工画框不参与去重)——**用 minIoU 而非 IoU**:RF-DETR 对同一目标常输出一大一小两个框(标准 IoU 仅 0.3~0.5 会漏杀,实测红黄重叠即此形态),大框套小框时小框被覆盖比例高,minIoU 命中;相邻目标两框互有外露,minIoU 通常 < 0.3;**全图扫描**(项目既定规则:不套用生成规格的位置裁剪,候选宁多勿漏);**单图框数上限(2026-09-02,定义于 consts.go `consts.MaxPrelabelPerImage`)**:去重后仍超上限按置信度取前 N(曾按生成声明数 animal_count 裁剪致漏目标——声明 1 个不代表图内只有 1 个;手动上传图同限);不设候选确认两阶段(2026-08-26 撤销 candidates_json);`POST /admin/label-tasks` 接口保留(服务端 `filenames` 参数支持指定图批量重标,2026-09-02 前端入口收敛后仍可全量——不传 filenames 即全量扫描) - **预标注四级漏斗(2026-09-04,树栖藏匿难例)**:树冠藏匿生成图(树栖物种目标仅占画面高 ~2%)在全图扫描下不可检——模型包固定 700x700 stretch 输入(见「预标注模型固定 700x700」,不支持动态分辨率,提高提交分辨率无增益),占图高 2% 的目标等比缩入后仅 ~15px 落在检出盲区。单张处理升级为逐级漏斗,**检测只信 RF-DETR,VLM 永不直接出框(只提议候选区)**:①**全图扫描**(现有 `Detect`,`localAi.threshold`)有候选即收(绝大多数图到此命中,不触发后级);②空检出 → **切片扫描**:`common.TileRegions` 滑窗切块(块长边/重叠比 = `localAi.tileSize`/`tileOverlap`,边缘块贴边收口保证全覆盖)逐块 `DetectRegion` 按低阈值(`localAi.tileThreshold`,小目标置信度天然偏低,量级取 RF-DETR-S 实测经验;低于全图丢弃线的候选仍入库落疑似 class 1,宁多勿漏)检测,块内坐标在 DetectRegion 内映射回原图像素,跨块重复检出由既有 minIoU 去重兜住;③仍空 → **VLM 提议候选区**:qwen3-vl-8b 按「宁可指错不可遗漏」提示词输出 ≤`consts.VlmLocateMaxRegions` 个归一化候选区(与手动藏匿位补检不同:此阶段图内无已确认框,无排除集;物种注入同 vlm-review——`gen_species` 空回退数据集名),逐区扩大 `consts.VlmRegionExpand` 倍(VLM 坐标偏粗,扩大给 RF-DETR 足够上下文,钳制图片边界)后 `DetectRegion` 精修,精修命中的框才采纳、未命中丢弃(VLM 误报被 RF-DETR 否掉);**VLM 失败/超时/输出非法一律跳过该级不阻塞任务**(三模型共存 + local-ai 服务端排队串行,无需显存互斥检查,2026-09-02 已放开);④全空 → 写 `'[]'`(语义不变:未标注 tab 展示等人工画框、不入自动补标池防乒乓)。漏斗各级候选同通道处理:conf ≥ confConfirmed 落 class 0、否则 class 1、minIoU 去重、单图上限 `consts.MaxPrelabelPerImage`,全图扫描不套生成规格的项目规则不变。单图超时 120s → `consts.LabelImageTimeoutSec`(切片 ~20 块串行 + VLM 兜底;切片在单图池任务内串行执行,不嵌套协程池) +- **标注审核状态机(2026-09-04)**:`dataset_image.review_status`(EnsureColumn 幂等补列):`0=未标注, 1=待审核, 2=已审核`;存量迁移——labels_json 非空数组→2(既有标注视为已审定稿)、null/''/'[]'→0。流转:预标注完成→1(**空检出保持 0**——无框可审,「确认无动物」应由人工画框/清洗表达,避免空图混入已审核口径)、App 用户提交→1、管理端工作台保存(AdminLabelSave)→2、审核「通过」→2、「拒绝」→ 清 labels_json + 回 0(图重新进任务池);**训练集打包(prepareYoloSet)只收 review_status=2**(标签质量闸门,原「labels_json 非空即收」作废);管理端图片 tab 由三变四:未标注(0)/待审核(1)/已标注(2)/已清洗,另加「标注任务」「标注记录」两个管理面板 tab(同在详情页,无独立菜单,2026-09-07 用户定案)——任务面板=本数据集任务下发/进度/停用,记录面板=本数据集众包记录查询/解冻(两列表接口支持 `datasetId` 过滤;`annotate_record` 领取时冗余 `dataset_id` 列,单表过滤不跨表);labeled_count 与 dataset.status='labeled' 口径同步改为 review_status=2 计数(2 必然非空框,无歧义) +- **自动标注退场(2026-09-04 用户定案)**:删除上传入库与生成完成(genTaskAutoLabel)两处 AutoLabel 触发及 autoSupplement 自动补标机制(补标池/空检出乒乓问题随机制一并消亡);**`localAi` 未配置不再阻断上传/生成**(标注改纯管理端手动动作,「不允许产生无标注图」强语义随自动标注退场删除);标注唯一入口 = 管理端勾选图片「预标」(AdminStartLabelTask 保留) +- **App 标注众包与时长激励(2026-09-04;图片粒度下发 2026-09-07 用户定案)**:App 用户处理标注任务换取使用时长,全链路进待审核,服务端记录每用户每张图的处理明细。**下发粒度(2026-09-07 修订)**:任务 = 管理端**勾选的具体图片集合**(不再是数据集级动态池)——`dataset_image.annotate_task_id`(EnsureColumn 幂等补列,默认 0=未下发)作占用标记:下发时 Serial 内批量写占用(校验属本数据集/未标注/未占用),**已下发图即从管理端「未标注」tab 消失**(tab 只显示 annotate_task_id=0);领取池 = 本任务占用且 review_status=0 且 clean_excluded=0 的图;停用任务时**释放未被领取过的图**(清 annotate_task_id,回到未标注 tab 可再下发),已领取未提交的可继续提交(有 annotate_record 的图不释放)。**表**:`annotate_task`(下发任务:dataset_id/name/status published|stopped/created_at);`annotate_record`(用户标注记录:phone_num/task_id/image_id/labels_json 提交快照/status pending|submitted|approved|rejected/created_at/submitted_at/reviewed_at——pending 即领取锁(partial unique index 同图仅一条 pending,超时在领取时惰性释放;**并发防重**:挑图 + 插锁全程在 Serial 临界区内串行执行,并发领取时后读方一定看到先读方刚插入的锁,同一张图不会分给多人,索引为跨实例兜底),UNIQUE(phone_num,image_id) 一人一图仅一次(拒后图仍在本任务池内可被他人再标),拒绝不删记录=惩罚统计依据);`reward_log`(发放流水:phone_num/minutes/task_id/granted_at——日上限按自然日求和);`license.annotate_frozen_until` + `license.annotate_stats_since`(EnsureColumn):冻结到期标记(>now 即冻结,时间戳对比天然自动解冻,无定时任务)与统计基线(冻结时重置,实现解冻后重新累计)。**规则**:领取 = 每次 `annotateReward.claimSize` 张(池内排除该用户已有 annotate_record 的图,冻结中/停用不可领);提交 = 框写 labels_json + review_status=1 + 记录置 submitted,每累计 `perImages`(10) 张 submitted 发 `minutes`(30) 分钟(license.expires_at = max(now,expires_at)+30min 分钟级顺延),当日已发 +30 > `dailyCapMinutes`(120) 则本次不发(App 展示今日已达上限);审核 = 管理端批量通过/拒绝,每次审核后重算该用户通过比例(reviewed_at ≥ stats_since 的 approved/(approved+rejected)),已审核样本 ≥ `minReviewed`(5) 且比例 < `freezeRatio`(0.8) → annotate_frozen_until=now+`freezeHours`(24h) 且 stats_since=now(惩罚不追溯扣已到账时长)。**接口**:admin 下发(传 `imageIds` 勾选图)/列表/停用任务、批量审核(通过/拒绝)、用户标注记录分页;App(/api/v1) 任务列表(含我的统计/冻结态)、领取、提交、我的统计——App 契约不变(仍按 taskId 领取),仅池语义变为任务占用集。**配置**:`config.yml annotateReward` 节点(数值以 config.yml 为准不在此复制),阈值类缺失回退 consts 默认 - **手动标注入口收敛(2026-09-02 决策)**:详情页删除「全量标注」按钮与图片卡片「审核」按钮,**标注入口仅两处(勾选图片驱动,顶栏)**:①「预标」= POST `/admin/label-tasks` 传选中图 `filenames`(只扫选中图,覆盖各图已有标注);②「补标」= 对选中图逐张 POST `/datasets/images/vlm-review`(VLM 藏匿位补检,追加疑似框,批量=前端循环单图接口,依赖已确认框作为排除基准)——VLM 补检按钮从标注编辑器迁出(2026-09-02);点卡片缩略图仍进人工编辑器(确认/清理/画框,编辑器内不再有 VLM 按钮),编辑器文案「图片审核」→「图片标注」;服务端全量重标能力保留但不再有 UI 入口 -- **详情页布局(2026-09-02 卡片化改版后快照)**:图片以缩略图卡片网格展示(每页 18 张,点卡片进弹窗编辑器);页顶标注/AI 生成任务进度条;**图片列表三 tab(2026-09-02)**:「未标注」(默认,待处理优先)、「已标注」、**「已清洗」**各带计数——已标注判定 = labels_json 非空(空检出 `[]` 的图仍在未标注,等人工画框);**已清洗 tab = clean_excluded=1 的图(数据清洗执行后从已标注 tab 移入,卡片角标同 tab 语义二选一),内提供逐张/批量恢复(= 清除标记,回「已标注」)**,恢复仅动作无删除;「数据清洗」按钮在已标注 tab 工具条(分析/执行入口,见下节);切 tab/翻页均清空勾选(防跨 tab 误标),编辑器「上一张/下一张」仅在当前 tab 列表内导航(连续标注不串到另一分类);**卡片上不再叠「已标注/未标注」角标**(2026-09-02,分类由 tab 表达),仅保留 AI 来源角标(付费资产提示) +- **详情页布局(2026-09-02 卡片化改版后快照)**:图片以缩略图卡片网格展示(每页 18 张,点卡片进弹窗编辑器);页顶标注/AI 生成任务进度条;**图片列表三 tab(2026-09-02)**:「未标注」(默认,待处理优先)、「已标注」、**「已清洗」**各带计数——已标注判定 = labels_json 非空(空检出 `[]` 的图仍在未标注,等人工画框);**未标注 tab 只显示未下发的图(annotate_task_id=0,2026-09-07 众包图片粒度下发,已下发图从 tab 消失,停用任务释放后回来)**;**已清洗 tab = clean_excluded=1 的图(数据清洗执行后从已标注 tab 移入,卡片角标同 tab 语义二选一),内提供逐张/批量恢复(= 清除标记,回「已标注」)**,恢复仅动作无删除;「数据清洗」按钮在已标注 tab 工具条(分析/执行入口,见下节);切 tab/翻页均清空勾选(防跨 tab 误标),编辑器「上一张/下一张」仅在当前 tab 列表内导航(连续标注不串到另一分类);**卡片上不再叠「已标注/未标注」角标**(2026-09-02,分类由 tab 表达),仅保留 AI 来源角标(付费资产提示) - **弹窗标注**:点击原图/标注图打开放大弹窗(大 canvas,1152x2048 原尺寸),支持两点画框/点框删除/清空/类别切换(确认 0/疑似 1)/上下一张/保存——**AI 自动标注与人工框同层可编辑**(含清理 AI 框);保存即整体覆写 `dataset_image.labels_json`(JSON 数组,YOLO 归一化 xywh+类别+置信度,空=清空标注);未保存修改切换图片有确认 - 无历史标注任务时详情页工作台数据源为 `GET /admin/label-workbench?dataset_id=`(图片 + 全量标注框),避免依赖人工先跑一次任务 - 训练前自动整理(prepare_yolo 逻辑服务端,**内存组装不落本地盘**):数据集有 labels → 按 80/20 拆 train/val 生成 YOLO 训练集包(标注 txt 内存生成、原图只记数据集目录源路径)→ 随同步推训练机(subprocess 直写 `workdir/datasetDir/yolo//`;ssh 走 **tar 流式管道**本地不落盘);**自动标注图直接进训练集**(人工修改/清理后即为训练数据),**2026-09-02 起跳过 `clean_excluded=1` 的图(数据清洗排除,见下)**;本地无暂存目录(旧 `workspace/datasets/yolo/` 与 `labels/` 已删除) @@ -422,6 +425,15 @@ training: - **执行语义(排除而非删除)**:候选清单人工过目后打 `dataset_image.clean_excluded=1`(EnsureColumn 迁移)——**只影响训练集打包**(prepare_yolo 跳过),图片/标注不动、列表/标注/预标全流程照常可见;**可恢复**(清除标记即回训练集,付费资产原则:清洗默认不删文件,物理删除仍走人工逐删) - 接口:`POST /admin/datasets/clean/preview`(datasetId + 可选 quotas → 各档分布/配额/超配候选清单 + 已排除列表);`POST /admin/datasets/clean/apply`(imageIds + exclude bool 批量置/清标记);**管理端详情页承载:「数据清洗」按钮在已标注 tab 工具条**(配额表可编辑 + 候选清单默认全选 → 执行排除),**已排除的图集中到「已清洗」tab 展示,逐张/批量恢复(apply exclude=false)**,图片列表接口返回 cleanExcluded 供前端归类 +### 负样本库(训练背景样本统一管理,2026-09-07) + +- **背景**:训练集为合成图(每张必含目标),模型从未学过「这不是目标」,端侧实拍把非目标动物/纹理误识别为动物。根治 = 训练时混入**负样本**(无任何标注框的背景图,ultralytics 对空标签图自动按背景学习,无需改训练脚本),负样本对全部物种数据集**统一一套**(误报源与物种无关:猫狗/树桩/草丛/真实场景背景通用)。 +- **实现决策:不建新表**——负样本库是一个特殊数据集:`dataset.source='negative'`、固定保留名 `__negative__`(consts.NegativeDatasetName,目录名同名;AdminCreateDataset 拒绝用户使用该保留名),完全复用 `dataset`/`dataset_image` 表与上传/删除/图片网格能力。首次进入管理端「负样本」tab 时 `POST /admin/datasets/negative` 懒创建(Serial 内防并发重建)。 +- **管理端「数据训练」页双 tab(2026-09-07 用户定案)**:「数据集」tab = 现有数据集卡片列表(列表接口排除负样本库),「负样本」tab = 负样本库图片网格(复用既有上传/删除接口,按负样本库 datasetId 调用;无标注/审核/清洗/预标/生成/众包流程)。 +- **训练打包混入(prepareYoloSet)**:打包主数据集后查负样本库,存在且有图则全部图片(不看 review_status/clean_excluded——负样本库无审核语义)加入训练包:文件名统一加 `neg_` 前缀(防与主数据集图重名覆盖),label txt 写空内容(= 背景图),随主数据集按同一 80/20 逻辑拆 train/val(val 含负样本可观测背景误检率);`data.yaml` 类别不变(负样本不引入新类)。无负样本库/库为空 → 行为与原来完全一致。 +- **入口防护**:预标注(AdminStartLabelTask)、AI 生成(AdminGenerateImages)、众包下发(AdminCreateTask)、发起训练(AdminStartTraining)对 `source='negative'` 数据集一律拒绝(负样本库单独训练无意义、其图片不需要标注)。 +- **删除防护**:AdminDeleteDataset 拒绝删除负样本库(防误删整库);单张图片删除照常可用。 + ### 模型热更新与多模型推理(客户端 Flutter) - `GET /api/v1/app/update` 扩展:响应追加 `models` 数组(`GET /api/v1/models` 同构:datasetId/datasetName/version/labels/sizeBytes/sha256/downloadUrl)——**多模型目录,不再有单一 modelUrl**;无发布模型时不返回该字段(旧 App 忽略、新 App 兼容旧服务器) diff --git a/server_admin/src/views/DatasetDetail.vue b/server_admin/src/views/DatasetDetail.vue index 50bd486..9c7c96d 100644 --- a/server_admin/src/views/DatasetDetail.vue +++ b/server_admin/src/views/DatasetDetail.vue @@ -52,26 +52,45 @@ const pageSize = ref(pageSizeOptions.includes(savedSize) ? savedSize : 20) // 列表 tab:未标注(默认,待处理优先) | 已标注 | 已清洗(clean_excluded=1,数据清洗排除,可恢复)—— // 卡片上不再叠「已标注/未标注」角标,分类由 tab 表达(2026-09-02) const listTab = ref('unlabeled') +// tab 分类(2026-09-04 审核三态):reviewStatus 0=未标注 / 1=待审核 / 2=已标注; +// 已清洗(clean_excluded=1)优先于审核态;已下发众包任务(annotateTaskId>0)的图从未标注 tab 隐藏 +// (2026-09-07 图片粒度下发,停用任务释放后回来) +const reviewStatusOf = (it) => it.reviewStatus ?? 0 const tabCounts = computed(() => { const imgs = detail.value?.images || [] - const cleaned = imgs.filter((it) => meta(it).cleanExcluded).length - const labeled = imgs.filter((it) => it.labeled && !meta(it).cleanExcluded).length - return { labeled, cleaned, unlabeled: imgs.length - labeled - cleaned } + const countRs = (rs) => + imgs.filter((it) => { + if (meta(it).cleanExcluded) return false + // 已下发众包任务只隐藏「未标注」口径(提交后进待审核/已标注照常计数) + if (rs === 0 && meta(it).annotateTaskId) return false + return reviewStatusOf(it) === rs + }).length + return { + unlabeled: countRs(0), + pending: countRs(1), + labeled: countRs(2), + cleaned: imgs.filter((it) => meta(it).cleanExcluded).length, + } }) const visibleImages = computed(() => { const imgs = detail.value?.images || [] + const rsMap = { unlabeled: 0, pending: 1, labeled: 2 } return imgs.filter((it) => { if (meta(it).cleanExcluded) return listTab.value === 'cleaned' - return (listTab.value === 'labeled') === !!it.labeled + if (listTab.value === 'unlabeled' && meta(it).annotateTaskId) return false + return reviewStatusOf(it) === rsMap[listTab.value] }) }) const pagedImages = computed(() => { const start = (page.value - 1) * pageSize.value return visibleImages.value.slice(start, start + pageSize.value).map((item, i) => ({ item, gi: start + i })) }) +// 图片类 tab 才渲染图片工具条与卡片网格;tasks/records 为管理面板 +const isImageTab = computed(() => listTab.value !== 'tasks' && listTab.value !== 'records') const emptyTip = computed(() => { - if (!tabCounts.unlabeled && !tabCounts.labeled && !tabCounts.cleaned) return '暂无图片,请上传或 AI 生成' + if (!tabCounts.unlabeled && !tabCounts.pending && !tabCounts.labeled && !tabCounts.cleaned) return '暂无图片,请上传或 AI 生成' if (listTab.value === 'cleaned') return '暂无已清洗图片(数据清洗排除的图在此,可勾选恢复)' + if (listTab.value === 'pending') return '暂无待审核图片(预标/众包提交的标注在此等人工审核)' return listTab.value === 'unlabeled' ? '暂无未标注图片' : '暂无已标注图片' }) @@ -98,7 +117,8 @@ async function loadAll() { } const imgs = await request.get('/datasets/images', { params: { datasetId: datasetId.value } }).catch(() => null) imageMeta.value = {} - for (const i of imgs?.list || []) imageMeta.value[i.filename] = { id: i.id, source: i.source, cleanExcluded: i.cleanExcluded || 0 } + for (const i of imgs?.list || []) + imageMeta.value[i.filename] = { id: i.id, source: i.source, cleanExcluded: i.cleanExcluded || 0, annotateTaskId: i.annotateTaskId || 0 } // 记录刷新前编辑中的图:刷新可能使其移出当前 tab(预标完成/删除/人工保存等), // 原 editIndex 会漂移到邻图,继续编辑保存会把框存错图——按 filename 重定位,找不到即关弹窗 const editFn = editorVisible.value && currentImage.value ? currentImage.value.filename : null @@ -322,6 +342,164 @@ function startLabelTask() { }) } +// 下发众包标注任务(2026-09-07 图片粒度:勾选的未标注图即任务图集,下发后从未标注 tab 消失, +// 停用任务释放未领取图回池;App 端按任务领取) +async function publishTask() { + if (!selected.value.length) return + const ids = (detail.value.images || []) + .filter((it) => selected.value.includes(it.filename)) + .map((it) => meta(it).id) + .filter(Boolean) + if (!ids.length) { + ElMessage.warning('选中图片缺少 id,请刷新后重试') + return + } + try { + const { value } = await ElMessageBox.prompt(`任务名(App 端展示),下发选中的 ${ids.length} 张图片`, '下发标注任务', { + inputValue: `${dataset.value?.name || ''} 标注任务`, + inputValidator: (v) => (!!v && v.trim().length <= 50) || '1~50 字', + confirmButtonText: '下发', + cancelButtonText: '取消', + }) + const res = await request.post('/annotate-tasks', { datasetId: datasetId.value, name: value.trim(), imageIds: ids }) + ElMessage.success(`任务已下发(id=${res.id}),选中的 ${ids.length} 张图已从未标注 tab 移除,App 用户可领取`) + selected.value = [] + await loadAll() + } catch (e) { + const msg = e?.message || e + if (msg !== 'cancel' && msg !== 'close') ElMessage.error(`任务下发失败:${msg}`) + } +} + +// 众包审核(待审核 tab 勾选驱动):通过 → 已标注(入训练集);拒绝 → 清标注回未标注池 +// 并计入对应众包用户低质统计(通过比例过低自动冻结) +const reviewing = ref(false) +async function reviewSelected(approve) { + if (!selected.value.length || reviewing.value) return + const ids = (detail.value.images || []) + .filter((it) => selected.value.includes(it.filename)) + .map((it) => meta(it).id) + .filter(Boolean) + if (!ids.length) { + ElMessage.warning('选中图片缺少 id,请刷新后重试') + return + } + try { + await ElMessageBox.confirm( + approve + ? `审核通过选中的 ${ids.length} 张图片?通过后转入已标注(纳入训练集)。` + : `拒绝选中的 ${ids.length} 张图片?将清除标注退回未标注池,并计入对应众包用户的低质统计。`, + '标注审核', + { type: 'warning', confirmButtonText: approve ? '通过' : '拒绝', cancelButtonText: '取消' }, + ) + } catch { + return + } + reviewing.value = true + try { + const res = await request.post('/annotate-review', { imageIds: ids, approve }) + const frozen = res?.frozenPhones || [] + ElMessage.success( + `${approve ? '通过' : '拒绝'} ${res?.affected ?? ids.length} 张${frozen.length ? `;低质冻结用户:${frozen.join('、')}` : ''}`, + ) + selected.value = [] + await loadAll() + } finally { + reviewing.value = false + } +} + +// ---------- 标注任务/标注记录面板(详情页 tab 内嵌,仅本数据集,切 tab 惰性加载) ---------- +const anLoading = ref(false) +const anTasks = ref([]) +const anTasksTotal = ref(0) +const anTaskPage = ref(1) +const anRecords = ref([]) +const anRecordsTotal = ref(0) +const anRecordPage = ref(1) +const anRecordPhone = ref('') +const anRecordStatus = ref('') +const anStatusOptions = [ + { value: '', label: '全部状态' }, + { value: 'pending', label: '领取中' }, + { value: 'submitted', label: '待审核' }, + { value: 'approved', label: '已通过' }, + { value: 'rejected', label: '已拒绝' }, +] +const anTagType = { pending: 'info', submitted: 'warning', approved: 'success', rejected: 'danger' } +const anTagText = { pending: '领取中', submitted: '待审核', approved: '已通过', rejected: '已拒绝' } + +async function loadAnnotateTasks() { + anLoading.value = true + try { + const d = await request.get('/annotate-tasks', { + params: { datasetId: datasetId.value, page: anTaskPage.value, size: 20 }, + }) + anTasks.value = d.list || [] + anTasksTotal.value = d.total || 0 + } finally { + anLoading.value = false + } +} + +async function stopAnnotateTask(row) { + try { + await ElMessageBox.confirm(`停用任务「${row.name}」?停用后 App 端不可再领取。`, '停用任务', { + type: 'warning', + confirmButtonText: '停用', + cancelButtonText: '取消', + }) + } catch { + return + } + await request.post('/annotate-tasks/stop', { id: row.id }) + ElMessage.success('已停用') + loadAnnotateTasks() +} + +async function loadAnnotateRecords() { + anLoading.value = true + try { + const d = await request.get('/annotate-records', { + params: { + datasetId: datasetId.value, + phone: anRecordPhone.value.trim(), + status: anRecordStatus.value, + page: anRecordPage.value, + size: 20, + }, + }) + anRecords.value = d.list || [] + anRecordsTotal.value = d.total || 0 + } finally { + anLoading.value = false + } +} + +async function unfreezeRecordUser() { + const target = anRecordPhone.value.trim() + if (!target) { + ElMessage.warning('请先在输入框填要解冻的手机号') + return + } + try { + await ElMessageBox.confirm(`解冻用户 ${target} 的标注资格?解冻后通过比例统计重新累计。`, '手动解冻', { + type: 'warning', + confirmButtonText: '解冻', + cancelButtonText: '取消', + }) + } catch { + return + } + await request.post('/annotate-unfreeze', { phone: target }) + ElMessage.success('已解冻') +} + +watch(listTab, (t) => { + if (t === 'tasks' && !anTasks.value.length) loadAnnotateTasks() + if (t === 'records' && !anRecords.value.length) loadAnnotateRecords() +}) + // VLM 藏匿位补检(「补标」按钮,勾选驱动批量执行):排除已确认框,按环境/光线/习性 // 找可能藏匿处,逐张追加疑似框(黄框,人工确认);依赖已确认框作为排除基准 const vlmReviewing = ref(false) @@ -974,9 +1152,10 @@ onBeforeUnmount(() => { {{ trainBanner.text }} - -
+
{ @click="openClean" >数据清洗(已排除 {{ tabCounts.cleaned }}) 通过{{ selected.length ? ` ${selected.length} 张` : '' }} + 拒绝{{ selected.length ? ` ${selected.length} 张` : '' }} + 预标{{ selected.length ? ` ${selected.length} 张` : '' }} 补标{{ selected.length ? ` ${selected.length} 张` : '' }} + + 下发标注任务{{ selected.length ? ` ${selected.length} 张` : '' }} {
未标注 {{ tabCounts.unlabeled }} + 待审核 {{ tabCounts.pending }} 已标注 {{ tabCounts.labeled }} 已清洗 {{ tabCounts.cleaned }} + 标注任务 + 标注记录
+ +
+
+ 下发入口:在「未标注」tab 勾选图片后点顶栏「下发标注任务」 + 刷新 +
+ + + + + + + + + + + + + + + + +
+ +
+
+ + +
+
+ + + + + 查询 + 解冻输入的手机号 +
+ + + + + + + + + + + + + + + + +
+ +
+
+ -
+