1
This commit is contained in:
@@ -12,6 +12,7 @@ import 'app_camera_controller.dart';
|
||||
import 'camera_view_model.dart';
|
||||
import 'detection_overlay.dart';
|
||||
import 'frame_analyzer.dart';
|
||||
import 'model_catalog_section.dart';
|
||||
|
||||
/// 主界面:相机预览 + 检测框 overlay + 顶栏(返回/切换摄像头)
|
||||
class CameraScreen extends StatefulWidget {
|
||||
@@ -40,50 +41,61 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||
void _openSettings() {
|
||||
final vm = _viewModel;
|
||||
if (vm == null) return;
|
||||
ModelManager.instance.refresh();
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.black87,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setSheetState) => Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('识别设置',
|
||||
style: TextStyle(
|
||||
color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Text('置信度阈值',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14)),
|
||||
const Spacer(),
|
||||
Text('${(_minScore * 100).toStringAsFixed(0)}%',
|
||||
style: const TextStyle(
|
||||
color: Colors.greenAccent,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
Slider(
|
||||
value: _minScore,
|
||||
min: 0.05,
|
||||
max: 0.50,
|
||||
divisions: 45,
|
||||
activeColor: Colors.greenAccent,
|
||||
onChanged: (v) {
|
||||
setSheetState(() => _minScore = v);
|
||||
_analyzer?.worker?.setMinScore(v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'阈值越低识别越灵敏(低分框越多,误报也可能增加);'
|
||||
'环颈雉鸡模型置信度普遍在 10%~20%,场景识别不到时可适当调低。',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
],
|
||||
builder: (ctx, setSheetState) => SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('识别设置',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Text('置信度阈值',
|
||||
style:
|
||||
TextStyle(color: Colors.white70, fontSize: 14)),
|
||||
const Spacer(),
|
||||
Text('${(_minScore * 100).toStringAsFixed(0)}%',
|
||||
style: const TextStyle(
|
||||
color: Colors.greenAccent,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
Slider(
|
||||
value: _minScore,
|
||||
min: 0.05,
|
||||
max: 0.50,
|
||||
divisions: 45,
|
||||
activeColor: Colors.greenAccent,
|
||||
onChanged: (v) {
|
||||
setSheetState(() => _minScore = v);
|
||||
_analyzer?.worker?.setMinScore(v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'阈值越低识别越灵敏(低分框越多,误报也可能增加);'
|
||||
'环颈雉鸡模型置信度普遍在 10%~20%,场景识别不到时可适当调低。',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(color: Colors.white12),
|
||||
const SizedBox(height: 8),
|
||||
ModelCatalogSection(manager: ModelManager.instance),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/model_manager.dart';
|
||||
|
||||
/// 设置弹层「模型清单」区块:2 列封面缩略图网格。
|
||||
/// 未下载 →「使用」点击后显示下载进度,完成自动激活;已下载未激活 → 直接激活;
|
||||
/// 已激活 → 再次点击取消;下载失败 → 失败提示 + 重试。
|
||||
class ModelCatalogSection extends StatelessWidget {
|
||||
final ModelManager manager;
|
||||
|
||||
const ModelCatalogSection({super.key, required this.manager});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: manager,
|
||||
builder: (context, _) {
|
||||
final items = manager.catalog;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text('模型清单',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold)),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: () => manager.refresh(),
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
label: const Text('刷新'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white70,
|
||||
visualDensity: VisualDensity.compact),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (manager.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(manager.error!,
|
||||
style: const TextStyle(
|
||||
color: Colors.orange, fontSize: 12)),
|
||||
),
|
||||
if (items.isEmpty)
|
||||
const Text('暂无已发布模型',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 13))
|
||||
else
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 0.72,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, i) =>
|
||||
_ModelCard(item: items[i], manager: manager),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModelCard extends StatelessWidget {
|
||||
final ModelCatalogItem item;
|
||||
final ModelManager manager;
|
||||
|
||||
const _ModelCard({required this.item, required this.manager});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final active = manager.isActive(item.datasetId);
|
||||
final downloaded = manager.isDownloaded(item.datasetId);
|
||||
final progress = manager.progressOf(item.datasetId);
|
||||
final error = manager.errorOf(item.datasetId);
|
||||
|
||||
final thumb = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 4 / 3,
|
||||
child: Image.network(
|
||||
'${manager.baseUrl}${item.coverUrl}',
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, chunk) => chunk == null
|
||||
? child
|
||||
: Container(
|
||||
color: Colors.white12,
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2)))),
|
||||
errorBuilder: (context, error, stack) => Container(
|
||||
color: Colors.white12,
|
||||
child: const Icon(Icons.image_not_supported_outlined,
|
||||
color: Colors.white38),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget action;
|
||||
if (progress != null) {
|
||||
action = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
LinearProgressIndicator(
|
||||
value: progress,
|
||||
backgroundColor: Colors.white12,
|
||||
color: Colors.greenAccent),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${(progress * 100).toStringAsFixed(0)}%',
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
const TextStyle(color: Colors.white70, fontSize: 11),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => manager.cancelDownload(item.datasetId),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white54,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
minimumSize: const Size(0, 24),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text('取消', style: TextStyle(fontSize: 11)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (error != null) {
|
||||
action = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(error,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.redAccent, fontSize: 10)),
|
||||
TextButton(
|
||||
onPressed: () => manager.downloadModel(item),
|
||||
child: const Text('重试', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (active) {
|
||||
action = SizedBox(
|
||||
height: 30,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => manager.setActive(item.datasetId, false),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.greenAccent,
|
||||
side: const BorderSide(color: Colors.greenAccent)),
|
||||
child: const Text('已使用', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
);
|
||||
} else if (downloaded) {
|
||||
action = SizedBox(
|
||||
height: 30,
|
||||
child: FilledButton(
|
||||
onPressed: () => manager.setActive(item.datasetId, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.greenAccent,
|
||||
foregroundColor: Colors.black,
|
||||
visualDensity: VisualDensity.compact),
|
||||
child: const Text('使用', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
action = SizedBox(
|
||||
height: 30,
|
||||
child: FilledButton(
|
||||
onPressed: () => manager.downloadModel(item),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.greenAccent,
|
||||
foregroundColor: Colors.black,
|
||||
visualDensity: VisualDensity.compact),
|
||||
child: const Text('使用', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white10,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: Center(child: thumb)),
|
||||
const SizedBox(height: 6),
|
||||
Text(item.datasetName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 2),
|
||||
Text('v${item.version}',
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 10)),
|
||||
const SizedBox(height: 6),
|
||||
action,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user