377 lines
12 KiB
Dart
377 lines
12 KiB
Dart
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;
|
||
}
|