1
This commit is contained in:
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<AnnotateTaskInfo>, 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<String, dynamic>()))
|
||||
.toList();
|
||||
final stats = AnnotateStats.fromJson(
|
||||
((json['stats'] as Map?) ?? const {}).cast<String, dynamic>());
|
||||
return (list, stats);
|
||||
}
|
||||
|
||||
/// 领取一批标注图片
|
||||
Future<List<AnnotateClaimImage>> 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<String, dynamic>()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 提交单张标注(boxes 元素:{class,cx,cy,w,h,confidence},归一化坐标)
|
||||
Future<AnnotateSubmitResult> submit(
|
||||
int imageId, List<Map<String, dynamic>> boxes) async {
|
||||
final json = await _post(
|
||||
'/api/v1/annotate/submit', jsonEncode({'imageId': imageId, 'boxes': boxes}));
|
||||
return AnnotateSubmitResult.fromJson(json);
|
||||
}
|
||||
|
||||
/// 我的统计
|
||||
Future<AnnotateStats> me() async {
|
||||
final json = await _get('/api/v1/annotate/me');
|
||||
return AnnotateStats.fromJson(
|
||||
((json['stats'] as Map?) ?? const {}).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
/// 图片完整 URL(Image.network 用,需带 Bearer 头)
|
||||
Uri imageUrl(String relative) => Uri.parse('$baseUrl$relative');
|
||||
|
||||
/// 图片请求头(标注器 Image.network 加载用)
|
||||
Future<Map<String, String>> imageHeaders() => _authHeaders();
|
||||
|
||||
Future<Map<String, String>> _authHeaders() async {
|
||||
final token = await sessionStore.readToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
throw const SessionExpiredException();
|
||||
}
|
||||
return {'Authorization': 'Bearer $token'};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _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<Map<String, dynamic>> _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<String, dynamic> _decode(http.Response res) {
|
||||
final Map<String, dynamic> json;
|
||||
try {
|
||||
json = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} 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<String, dynamic>;
|
||||
}
|
||||
}
|
||||
@@ -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<AnnotateTasksScreen> createState() => _AnnotateTasksScreenState();
|
||||
}
|
||||
|
||||
class _AnnotateTasksScreenState extends State<AnnotateTasksScreen> {
|
||||
late Future<(List<AnnotateTaskInfo>, AnnotateStats)> _future;
|
||||
bool _claiming = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = widget.api.tasks();
|
||||
}
|
||||
|
||||
void _reload() {
|
||||
setState(() => _future = widget.api.tasks());
|
||||
}
|
||||
|
||||
Future<void> _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<bool>(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<AnnotateTaskInfo>, 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),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<AnnotateClaimImage> images;
|
||||
|
||||
const AnnotatorScreen({
|
||||
super.key,
|
||||
required this.api,
|
||||
required this.task,
|
||||
required this.images,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnnotatorScreen> 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<AnnotatorScreen> {
|
||||
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<void> _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
|
||||
? <Map<String, dynamic>>[]
|
||||
: _boxes
|
||||
.map((b) => <String, dynamic>{
|
||||
'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<Map<String, String>>(
|
||||
future: widget.api.imageHeaders(),
|
||||
builder: (context, snap) {
|
||||
final headers = snap.data ?? const <String, String>{};
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<HomeScreen> {
|
||||
if (mounted) context.read<HomeViewModel>().refresh();
|
||||
}
|
||||
|
||||
/// 标注赚时长:处理众包标注任务获取使用时长
|
||||
Future<void> _onAnnotate() async {
|
||||
final api = context.read<AppContainer>().annotateApi;
|
||||
await Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => AnnotateTasksScreen(api: api),
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _toLogin() async {
|
||||
final container = context.read<SessionStore>();
|
||||
await container.clear();
|
||||
@@ -148,9 +158,20 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
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) ...[
|
||||
|
||||
+27
-7
@@ -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/<name>/`,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/<name>/`,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/<name>/`(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://<host>/admin/`。金额均为整数分,前端展示 ÷100 转元。
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
@@ -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,13 +194,15 @@ 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=已清洗排除
|
||||
AnnotateTaskId int64 `json:"annotateTaskId"` // 众包任务占用标记(0=未下发)
|
||||
CreatedAt *gtime.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -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 自动标注或人工保存)
|
||||
Labeled bool `json:"labeled"` // 有标注框(AI 预标或人工保存)
|
||||
ReviewStatus int `json:"reviewStatus"` // 审核三态 0=未标注,1=待审核,2=已审核
|
||||
}
|
||||
|
||||
type AdminLabelTaskDetailRes struct {
|
||||
|
||||
@@ -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:"审核时间"`
|
||||
}
|
||||
@@ -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:"下发时间"`
|
||||
}
|
||||
@@ -10,7 +10,9 @@ type DatasetImage struct {
|
||||
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/''/'[]'=未标注)"`
|
||||
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:"创建时间"`
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import "github.com/gogf/gf/v2/os/gtime"
|
||||
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"`
|
||||
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:"更新时间"`
|
||||
|
||||
@@ -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:"发放时间"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -1472,6 +1472,7 @@ func (s *datasetService) AdminListImages(ctx context.Context, req *dto.AdminData
|
||||
Source: v.Source,
|
||||
Url: datasetImageUrl(ctx, dataset.Id, v.Filename),
|
||||
CleanExcluded: v.CleanExcluded,
|
||||
AnnotateTaskId: v.AnnotateTaskId,
|
||||
CreatedAt: v.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
apk
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
+1
-1
@@ -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) {
|
||||
|
||||
+13
-1
@@ -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/<name>/`;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 兼容旧服务器)
|
||||
|
||||
@@ -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(() => {
|
||||
<el-tag v-if="dataset" :type="trainBanner.type" size="small">{{ trainBanner.text }}</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- 图片工具:未标注/已标注 tab 走勾选预标/补标/删除;已标注 tab 另有数据清洗入口;
|
||||
<!-- 图片工具(仅图片类 tab 显示;标注任务/记录为管理面板无勾选操作):
|
||||
未标注/已标注 tab 走勾选预标/补标/删除;已标注 tab 另有数据清洗入口;
|
||||
已清洗 tab(clean_excluded=1)工具条改为恢复驱动(2026-09-02) -->
|
||||
<div class="img-toolbar">
|
||||
<div v-if="isImageTab" class="img-toolbar">
|
||||
<el-button
|
||||
:disabled="!pagedImages.length"
|
||||
@click="toggleSelectAll"
|
||||
@@ -988,17 +1167,38 @@ onBeforeUnmount(() => {
|
||||
@click="openClean"
|
||||
>数据清洗<span v-if="tabCounts.cleaned" class="toolbar-note">(已排除 {{ tabCounts.cleaned }})</span></el-button>
|
||||
<el-button
|
||||
v-if="listTab !== 'cleaned'"
|
||||
v-if="listTab === 'pending'"
|
||||
type="success"
|
||||
:disabled="reviewing || !selected.length"
|
||||
@click="reviewSelected(true)"
|
||||
>通过{{ selected.length ? ` ${selected.length} 张` : '' }}</el-button>
|
||||
<el-button
|
||||
v-if="listTab === 'pending'"
|
||||
type="danger"
|
||||
plain
|
||||
:disabled="reviewing || !selected.length"
|
||||
@click="reviewSelected(false)"
|
||||
>拒绝{{ selected.length ? ` ${selected.length} 张` : '' }}</el-button>
|
||||
<el-button
|
||||
v-if="listTab === 'unlabeled' || listTab === 'labeled'"
|
||||
:icon="MagicStick"
|
||||
:disabled="labelRunning || !selected.length"
|
||||
@click="startLabelTask"
|
||||
>预标{{ selected.length ? ` ${selected.length} 张` : '' }}</el-button>
|
||||
<el-button
|
||||
v-if="listTab !== 'cleaned'"
|
||||
v-if="listTab === 'unlabeled' || listTab === 'labeled'"
|
||||
:icon="Search"
|
||||
:disabled="vlmReviewing || !selected.length"
|
||||
@click="vlmBatchReview"
|
||||
>补标{{ selected.length ? ` ${selected.length} 张` : '' }}</el-button>
|
||||
<!-- 下发众包任务(2026-09-07 图片粒度):勾选的未标注图即任务图集 -->
|
||||
<el-button
|
||||
v-if="listTab === 'unlabeled'"
|
||||
type="warning"
|
||||
plain
|
||||
:disabled="!selected.length"
|
||||
@click="publishTask"
|
||||
>下发标注任务{{ selected.length ? ` ${selected.length} 张` : '' }}</el-button>
|
||||
<el-button
|
||||
v-if="listTab === 'cleaned'"
|
||||
type="primary"
|
||||
@@ -1045,13 +1245,109 @@ onBeforeUnmount(() => {
|
||||
<div class="list-tabs">
|
||||
<el-radio-group v-model="listTab" size="small">
|
||||
<el-radio-button value="unlabeled">未标注 {{ tabCounts.unlabeled }}</el-radio-button>
|
||||
<el-radio-button value="pending">待审核 {{ tabCounts.pending }}</el-radio-button>
|
||||
<el-radio-button value="labeled">已标注 {{ tabCounts.labeled }}</el-radio-button>
|
||||
<el-radio-button value="cleaned">已清洗 {{ tabCounts.cleaned }}</el-radio-button>
|
||||
<el-radio-button value="tasks">标注任务</el-radio-button>
|
||||
<el-radio-button value="records">标注记录</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 标注任务面板(仅本数据集:进度/停用;下发入口在未标注 tab 勾选图片顶栏) -->
|
||||
<div v-if="listTab === 'tasks'" v-loading="anLoading" class="rows-area">
|
||||
<div class="panel-toolbar">
|
||||
<span class="panel-tip">下发入口:在「未标注」tab 勾选图片后点顶栏「下发标注任务」</span>
|
||||
<el-button @click="loadAnnotateTasks">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="anTasks" size="small">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="name" label="任务名" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="poolRemain" label="池余量" width="80" />
|
||||
<el-table-column prop="pending" label="领取中" width="80" />
|
||||
<el-table-column prop="submitted" label="待审核" width="80" />
|
||||
<el-table-column prop="approved" label="已通过" width="80" />
|
||||
<el-table-column prop="rejected" label="已拒绝" width="80" />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'published' ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 'published' ? '进行中' : '已停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="下发时间" width="170" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="80" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status === 'published'" link type="danger" @click="stopAnnotateTask(row)">停用</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
:current-page="anTaskPage"
|
||||
:page-size="20"
|
||||
:total="anTasksTotal"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="(p) => { anTaskPage = p; loadAnnotateTasks() }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标注记录面板(仅本数据集:每用户每张图的处理明细 + 手动解冻) -->
|
||||
<div v-else-if="listTab === 'records'" v-loading="anLoading" class="rows-area">
|
||||
<div class="panel-toolbar">
|
||||
<el-input
|
||||
v-model="anRecordPhone"
|
||||
placeholder="手机号精确过滤"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
@keyup.enter="() => { anRecordPage = 1; loadAnnotateRecords() }"
|
||||
/>
|
||||
<el-select v-model="anRecordStatus" style="width: 130px" @change="() => { anRecordPage = 1; loadAnnotateRecords() }">
|
||||
<el-option v-for="o in anStatusOptions" :key="o.value" :value="o.value" :label="o.label" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="() => { anRecordPage = 1; loadAnnotateRecords() }">查询</el-button>
|
||||
<el-button type="warning" plain @click="unfreezeRecordUser">解冻输入的手机号</el-button>
|
||||
</div>
|
||||
<el-table :data="anRecords" size="small">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="phoneNum" label="手机号" width="130" />
|
||||
<el-table-column label="图片" width="76">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.imageUrl"
|
||||
:src="imgUrl(row.imageUrl)"
|
||||
:preview-src-list="[imgUrl(row.imageUrl)]"
|
||||
preview-teleported
|
||||
fit="cover"
|
||||
style="width: 56px; height: 56px; border-radius: 4px"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="anTagType[row.status] || 'info'" size="small">{{ anTagText[row.status] || row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="框数" width="70">
|
||||
<template #default="{ row }">{{ (row.boxes || []).length }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="领取时间" width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="submittedAt" label="提交时间" width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="reviewedAt" label="审核时间" width="170" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
:current-page="anRecordPage"
|
||||
:page-size="20"
|
||||
:total="anRecordsTotal"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="(p) => { anRecordPage = p; loadAnnotateRecords() }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片卡片:点击缩略图直接进标注弹窗(有标注框直接显示在图上,可继续画框) -->
|
||||
<div v-loading="loading" class="rows-area">
|
||||
<div v-else-if="isImageTab" v-loading="loading" class="rows-area">
|
||||
<template v-if="pagedImages.length">
|
||||
<div class="card-grid">
|
||||
<div v-for="{ item, gi } in pagedImages" :key="item.filename" class="img-card">
|
||||
@@ -1301,6 +1597,17 @@ onBeforeUnmount(() => {
|
||||
flex-wrap: wrap;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.panel-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.label-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -42,6 +42,88 @@ const coverDeleted = ref(false) // 用户删除了已有封面(保存时调删
|
||||
|
||||
const trainingIds = reactive(new Set()) // 训练发起中的数据集 id(请求期间按钮置灰防重复点击)
|
||||
|
||||
// ---------- 负样本库(技术设计.md「负样本库」):source=negative 特殊数据集,
|
||||
// 统一背景样本训练打包时混入全部物种数据集;图片无标注/审核流程,仅上传/删除 ----------
|
||||
|
||||
const pageTab = ref('datasets') // datasets 数据集卡片 | negatives 负样本库
|
||||
const negLib = ref(null) // 负样本库数据集记录 {id, name, imageCount}
|
||||
const negList = ref([]) // 图片列表 [{id, filename, url}]
|
||||
const negLoading = ref(false)
|
||||
const negUploading = ref(false)
|
||||
const negSelected = ref(new Set()) // 勾选的图片 id
|
||||
|
||||
function negImageUrl(filename) {
|
||||
return `${location.origin}/api/v1/admin/datasets/image?datasetId=${negLib.value.id}&filename=${encodeURIComponent(filename)}&token=${encodeURIComponent(localStorage.getItem('adminToken') || '')}`
|
||||
}
|
||||
|
||||
// 懒创建 + 拉图片列表(进入负样本 tab 时调用;库不存在后端自动创建)
|
||||
function loadNegatives() {
|
||||
negLoading.value = true
|
||||
request
|
||||
.post('/datasets/negative')
|
||||
.then((lib) => {
|
||||
negLib.value = lib
|
||||
return request.get('/datasets/images', { params: { datasetId: lib.id } }).then((d) => {
|
||||
negList.value = (d.list || []).map((i) => ({ id: i.id, filename: i.filename, url: negImageUrl(i.filename) }))
|
||||
negSelected.value = new Set()
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
negLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
pageTab.value = tab
|
||||
if (tab === 'negatives' && !negLib.value) loadNegatives()
|
||||
}
|
||||
|
||||
const fileInput = ref(null)
|
||||
function pickFiles() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
async function onFilesPicked(e) {
|
||||
const files = Array.from(e.target.files || [])
|
||||
e.target.value = ''
|
||||
if (!files.length || !negLib.value) return
|
||||
negUploading.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('datasetId', negLib.value.id)
|
||||
for (const f of files) fd.append('files', f)
|
||||
const res = await request.post('/datasets/upload', fd, { timeout: 300000 })
|
||||
ElMessage.success(`已上传 ${res.added} 张${res.skipped ? `,重名跳过 ${res.skipped} 张` : ''}`)
|
||||
loadNegatives()
|
||||
} catch {
|
||||
} finally {
|
||||
negUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function removeNegSelected() {
|
||||
const ids = [...negSelected.value]
|
||||
if (!ids.length) return
|
||||
ElMessageBox.confirm(`确定删除选中的 ${ids.length} 张负样本图片?删除后无法恢复。`, '删除负样本', {
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(() => request.post('/datasets/images/delete', { datasetId: negLib.value.id, ids }))
|
||||
.then(() => {
|
||||
ElMessage.success('已删除')
|
||||
loadNegatives()
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
function toggleNeg(id) {
|
||||
const s = new Set(negSelected.value)
|
||||
if (s.has(id)) s.delete(id)
|
||||
else s.add(id)
|
||||
negSelected.value = s
|
||||
}
|
||||
|
||||
const statusMap = { building: '建设中', labeled: '已标注', synced: '已同步' }
|
||||
const statusTag = { building: 'info', labeled: 'success', synced: 'primary' }
|
||||
const trainStatusMap = { queued: '排队中', running: '训练中', success: '已训练', failed: '训练失败' }
|
||||
@@ -413,6 +495,16 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<el-card shadow="never">
|
||||
<!-- 训练数据双 tab(2026-09-07):数据集 = 物种数据集卡片;负样本 = 统一背景样本库
|
||||
(无标注,训练打包时自动混入全部数据集) -->
|
||||
<div class="page-tabs">
|
||||
<el-radio-group :model-value="pageTab" size="small" @update:model-value="switchTab">
|
||||
<el-radio-button value="datasets">数据集</el-radio-button>
|
||||
<el-radio-button value="negatives">负样本{{ negLib ? ` ${negLib.imageCount}` : '' }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<template v-if="pageTab === 'datasets'">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<el-input
|
||||
@@ -509,6 +601,37 @@ onBeforeUnmount(() => {
|
||||
@change="load"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 负样本 tab:上传/删除/网格,无标注与训练流程(训练打包自动混入) -->
|
||||
<template v-else>
|
||||
<div class="toolbar">
|
||||
<div class="neg-tip">
|
||||
负样本 = 不含目标动物的背景图(草地/树丛/误报源如猫狗树桩等)。训练打包时统一混入全部物种数据集
|
||||
(空标签 = 背景),教会模型「这不是目标动物」,无需任何标注。
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<input ref="fileInput" type="file" accept=".jpg,.jpeg,.png" multiple hidden @change="onFilesPicked" />
|
||||
<el-button type="primary" :icon="Plus" :loading="negUploading" @click="pickFiles">上传图片</el-button>
|
||||
<el-button type="danger" :disabled="!negSelected.size" @click="removeNegSelected">
|
||||
删除选中{{ negSelected.size ? ` ${negSelected.size} 张` : '' }}
|
||||
</el-button>
|
||||
<el-button :loading="negLoading" @click="loadNegatives">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-loading="negLoading" class="neg-grid">
|
||||
<div
|
||||
v-for="img in negList"
|
||||
:key="img.id"
|
||||
class="neg-cell"
|
||||
:class="{ 'is-sel': negSelected.has(img.id) }"
|
||||
@click="toggleNeg(img.id)"
|
||||
>
|
||||
<img :src="img.url" :alt="img.filename" loading="lazy" />
|
||||
</div>
|
||||
<el-empty v-if="!negLoading && !negList.length" description="暂无负样本,点击右上角上传" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 发起训练:档位选择(默认双档 s+n;训练机 GPU 独占串行,有任务时新任务排队 queued 逐个执行) -->
|
||||
<el-dialog v-model="startVisible" title="发起训练" width="min(440px, 94vw)">
|
||||
@@ -611,6 +734,39 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-tabs {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.neg-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.6;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.neg-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
min-height: 120px;
|
||||
}
|
||||
.neg-cell {
|
||||
aspect-ratio: 4 / 3;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.neg-cell img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.neg-cell.is-sel {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
Reference in New Issue
Block a user