Files
2026-09-07 10:11:36 +08:00

377 lines
12 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,h0~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;
}