视觉分析(模拟)app
This commit is contained in:
32
lib/main.dart
Normal file
32
lib/main.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'screens/welcome_screen.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// 设置系统UI样式
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'sight-identification',
|
||||
theme: ThemeData(
|
||||
primarySwatch: Colors.blue,
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const WelcomeScreen(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
462
lib/screens/camera_screen.dart
Normal file
462
lib/screens/camera_screen.dart
Normal file
@@ -0,0 +1,462 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:gal/gal.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:camera_app/services/yolo_service.dart';
|
||||
import 'package:camera_app/screens/result_screen.dart';
|
||||
|
||||
class CameraScreen extends StatefulWidget {
|
||||
const CameraScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CameraScreen> createState() => _CameraScreenState();
|
||||
}
|
||||
|
||||
class _CameraScreenState extends State<CameraScreen> {
|
||||
CameraController? _controller;
|
||||
List<CameraDescription>? _cameras;
|
||||
bool _isInitialized = false;
|
||||
bool _isCapturing = false;
|
||||
int _photoCount = 0;
|
||||
Timer? _timer;
|
||||
List<String> _savedPhotoPaths = [];
|
||||
bool _isWebOrDesktop = false;
|
||||
final YOLOService _yoloService = YOLOService();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeCamera();
|
||||
}
|
||||
|
||||
Future<void> _initializeCamera() async {
|
||||
try {
|
||||
// Web 和桌面平台使用 image_picker
|
||||
if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isWebOrDesktop = true;
|
||||
_isInitialized = true; // Web/桌面平台直接显示界面
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 移动平台使用 camera 包
|
||||
await _requestPermissions();
|
||||
|
||||
_cameras = await availableCameras();
|
||||
if (_cameras == null || _cameras!.isEmpty) {
|
||||
_showError('未找到可用的相机');
|
||||
return;
|
||||
}
|
||||
|
||||
_controller = CameraController(
|
||||
_cameras![0],
|
||||
ResolutionPreset.high,
|
||||
enableAudio: false,
|
||||
);
|
||||
|
||||
await _controller!.initialize();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInitialized = true;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_showError('初始化相机失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestPermissions() async {
|
||||
if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) {
|
||||
return; // Web/桌面平台权限由浏览器/系统处理
|
||||
}
|
||||
|
||||
final cameraStatus = await Permission.camera.request();
|
||||
if (!cameraStatus.isGranted) {
|
||||
_showError('需要相机权限才能使用此功能');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final photosStatus = await Permission.photos.request();
|
||||
if (photosStatus.isDenied || photosStatus.isPermanentlyDenied) {
|
||||
final storageStatus = await Permission.storage.request();
|
||||
if (!storageStatus.isGranted) {
|
||||
_showError('需要存储权限才能保存图片');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (Platform.isIOS) {
|
||||
final photosStatus = await Permission.photos.request();
|
||||
if (!photosStatus.isGranted) {
|
||||
_showError('需要照片权限才能保存图片');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startCapturing() async {
|
||||
if (_isWebOrDesktop) {
|
||||
_showError('Web/桌面平台不支持自动连续拍照,请使用移动设备');
|
||||
return;
|
||||
}
|
||||
|
||||
// 移动平台:自动拍照模式
|
||||
if (!_isInitialized || _controller == null || !_controller!.value.isInitialized) {
|
||||
_showError('相机未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isCapturing = true;
|
||||
_photoCount = 0;
|
||||
_savedPhotoPaths.clear();
|
||||
});
|
||||
|
||||
// 立即拍摄第一张
|
||||
await _takePhoto();
|
||||
|
||||
// 然后每2秒拍摄一张,直到点击停止
|
||||
_timer = Timer.periodic(const Duration(seconds: 2), (timer) async {
|
||||
if (_isCapturing) {
|
||||
await _takePhoto();
|
||||
} else {
|
||||
timer.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _stopCapturing() async {
|
||||
setState(() {
|
||||
_isCapturing = false;
|
||||
});
|
||||
_timer?.cancel();
|
||||
|
||||
if (_savedPhotoPaths.isEmpty) {
|
||||
_showError('没有拍摄照片');
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示分析中对话框
|
||||
if (!mounted) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
return const AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('正在分析照片...'),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
// 使用YOLO分析照片
|
||||
final analysisResults = await _yoloService.analyzeImages(_savedPhotoPaths);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.of(context).pop(); // 关闭分析中对话框
|
||||
|
||||
// 导航到结果页面
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ResultScreen(
|
||||
photoPaths: _savedPhotoPaths,
|
||||
analysisResults: analysisResults,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.of(context).pop(); // 关闭分析中对话框
|
||||
_showError('分析照片失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _takePhoto() async {
|
||||
if (_controller == null || !_controller!.value.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final XFile photo = await _controller!.takePicture();
|
||||
await _savePhoto(photo.path);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_photoCount++;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showError('拍照失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _savePhoto(String photoPath) async {
|
||||
try {
|
||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
|
||||
// 移动平台:保存到相册
|
||||
await Gal.putImage(photoPath);
|
||||
} else {
|
||||
// Web/桌面平台:保存到下载目录
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final fileName = 'photo_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
final savedPath = path.join(appDir.path, fileName);
|
||||
await File(photoPath).copy(savedPath);
|
||||
}
|
||||
_savedPhotoPaths.add(photoPath);
|
||||
} catch (e) {
|
||||
// 保存失败不影响流程
|
||||
_savedPhotoPaths.add(photoPath);
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_controller?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_isInitialized) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Web/桌面平台界面
|
||||
if (_isWebOrDesktop) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.camera_alt,
|
||||
size: 100,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'$_photoCount 张',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Web/桌面平台不支持自动连续拍照',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 18,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
tooltip: '返回',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 移动平台:显示相机预览
|
||||
if (_controller == null || !_controller!.value.isInitialized) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: const Center(
|
||||
child: Text(
|
||||
'相机初始化中...',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
SizedBox.expand(child: CameraPreview(_controller!)),
|
||||
|
||||
SafeArea(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.7),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
onPressed: () {
|
||||
if (_isCapturing) {
|
||||
_stopCapturing();
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
if (_isCapturing)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.8),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$_photoCount 张',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.7),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: _isCapturing
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: _stopCapturing,
|
||||
icon: const Icon(Icons.stop, size: 28),
|
||||
label: const Text(
|
||||
'停止拍照',
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 16,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'已拍摄 $_photoCount 张照片',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: ElevatedButton.icon(
|
||||
onPressed: _startCapturing,
|
||||
icon: const Icon(Icons.camera_alt, size: 28),
|
||||
label: const Text(
|
||||
'开始拍照',
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 16,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
294
lib/screens/result_screen.dart
Normal file
294
lib/screens/result_screen.dart
Normal file
@@ -0,0 +1,294 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:camera_app/services/yolo_service.dart';
|
||||
|
||||
class ResultScreen extends StatefulWidget {
|
||||
final List<String> photoPaths;
|
||||
final Map<String, YOLOAnalysisResult> analysisResults;
|
||||
|
||||
const ResultScreen({
|
||||
super.key,
|
||||
required this.photoPaths,
|
||||
required this.analysisResults,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ResultScreen> createState() => _ResultScreenState();
|
||||
}
|
||||
|
||||
class _ResultScreenState extends State<ResultScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
title: const Text('分析结果'),
|
||||
backgroundColor: Colors.blue.shade700,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// 统计信息卡片
|
||||
Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.blue.shade200),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.photo_library, color: Colors.blue.shade700),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'共拍摄 ${widget.photoPaths.length} 张照片',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue.shade900,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'已分析 ${widget.analysisResults.length} 张',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.blue.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 分析结果列表
|
||||
Expanded(
|
||||
child: widget.analysisResults.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 64,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'暂无分析结果',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: widget.photoPaths.length,
|
||||
itemBuilder: (context, index) {
|
||||
final photoPath = widget.photoPaths[index];
|
||||
final result = widget.analysisResults[photoPath];
|
||||
|
||||
return _buildPhotoAnalysisCard(photoPath, result, index);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 完成按钮
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
// 返回欢迎页面
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue.shade700,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'完成',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhotoAnalysisCard(
|
||||
String photoPath,
|
||||
YOLOAnalysisResult? result,
|
||||
int index,
|
||||
) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 图片预览
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
child: Image.file(
|
||||
File(photoPath),
|
||||
width: double.infinity,
|
||||
height: 200,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 200,
|
||||
color: Colors.grey.shade200,
|
||||
child: const Icon(Icons.broken_image, size: 64),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 分析结果
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.image, color: Colors.blue.shade700),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'照片 ${index + 1}',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey.shade800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
if (result == null)
|
||||
const Text(
|
||||
'分析中...',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
)
|
||||
else if (result.error != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.shade200),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning, color: Colors.orange.shade700),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
result.error!,
|
||||
style: TextStyle(color: Colors.orange.shade900),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (result.objects.isEmpty)
|
||||
const Text(
|
||||
'未检测到物体',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'检测到 ${result.objects.length} 个物体:',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...result.objects.map((obj) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.green.shade200),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.label, color: Colors.green.shade700),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
obj.label,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.green.shade900,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'置信度: ${(obj.confidence * 100).toStringAsFixed(1)}%',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.green.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
97
lib/screens/welcome_screen.dart
Normal file
97
lib/screens/welcome_screen.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'camera_screen.dart';
|
||||
|
||||
class WelcomeScreen extends StatelessWidget {
|
||||
const WelcomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.blue.shade400,
|
||||
Colors.blue.shade700,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 图标
|
||||
Icon(
|
||||
Icons.camera_alt,
|
||||
size: 100,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
|
||||
// 标题
|
||||
const Text(
|
||||
'sight-identification',
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 说明文字
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Text(
|
||||
'点击按钮开始自动拍照\n每2秒拍摄一次',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 60),
|
||||
|
||||
// 开始按钮
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CameraScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.blue.shade700,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 50,
|
||||
vertical: 18,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
elevation: 5,
|
||||
),
|
||||
child: const Text(
|
||||
'开始拍照',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
147
lib/services/yolo_service.dart
Normal file
147
lib/services/yolo_service.dart
Normal file
@@ -0,0 +1,147 @@
|
||||
import 'dart:io';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'dart:convert';
|
||||
|
||||
class YOLOAnalysisResult {
|
||||
final List<DetectedObject> objects;
|
||||
final String? error;
|
||||
|
||||
YOLOAnalysisResult({
|
||||
required this.objects,
|
||||
this.error,
|
||||
});
|
||||
|
||||
factory YOLOAnalysisResult.fromJson(Map<String, dynamic> json) {
|
||||
if (json.containsKey('error')) {
|
||||
return YOLOAnalysisResult(
|
||||
objects: [],
|
||||
error: json['error'],
|
||||
);
|
||||
}
|
||||
|
||||
List<DetectedObject> objects = [];
|
||||
if (json.containsKey('objects') || json.containsKey('detections')) {
|
||||
final detections = json['objects'] ?? json['detections'] ?? [];
|
||||
objects = (detections as List)
|
||||
.map((item) => DetectedObject.fromJson(item))
|
||||
.toList();
|
||||
}
|
||||
|
||||
return YOLOAnalysisResult(objects: objects);
|
||||
}
|
||||
}
|
||||
|
||||
class DetectedObject {
|
||||
final String label;
|
||||
final double confidence;
|
||||
final BoundingBox bbox;
|
||||
|
||||
DetectedObject({
|
||||
required this.label,
|
||||
required this.confidence,
|
||||
required this.bbox,
|
||||
});
|
||||
|
||||
factory DetectedObject.fromJson(Map<String, dynamic> json) {
|
||||
return DetectedObject(
|
||||
label: json['label'] ?? json['class'] ?? json['name'] ?? 'Unknown',
|
||||
confidence: (json['confidence'] ?? json['score'] ?? 0.0).toDouble(),
|
||||
bbox: BoundingBox.fromJson(json['bbox'] ?? json['box'] ?? {}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BoundingBox {
|
||||
final double x;
|
||||
final double y;
|
||||
final double width;
|
||||
final double height;
|
||||
|
||||
BoundingBox({
|
||||
required this.x,
|
||||
required this.y,
|
||||
required this.width,
|
||||
required this.height,
|
||||
});
|
||||
|
||||
factory BoundingBox.fromJson(Map<String, dynamic> json) {
|
||||
return BoundingBox(
|
||||
x: (json['x'] ?? json['x1'] ?? 0.0).toDouble(),
|
||||
y: (json['y'] ?? json['y1'] ?? 0.0).toDouble(),
|
||||
width: (json['width'] ?? (json['x2'] ?? 0.0) - (json['x1'] ?? 0.0)).toDouble(),
|
||||
height: (json['height'] ?? (json['y2'] ?? 0.0) - (json['y1'] ?? 0.0)).toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class YOLOService {
|
||||
// 配置YOLO API端点(用户可以修改)
|
||||
static const String yoloApiUrl = 'http://10.168.2.249:3000/api/upload';
|
||||
|
||||
// 分析单张图片
|
||||
Future<YOLOAnalysisResult> analyzeImage(String imagePath) async {
|
||||
try {
|
||||
final file = File(imagePath);
|
||||
if (!await file.exists()) {
|
||||
return YOLOAnalysisResult(
|
||||
objects: [],
|
||||
error: '图片文件不存在',
|
||||
);
|
||||
}
|
||||
|
||||
// 创建multipart请求
|
||||
var request = http.MultipartRequest('POST', Uri.parse(yoloApiUrl));
|
||||
request.files.add(
|
||||
await http.MultipartFile.fromPath('image', imagePath),
|
||||
);
|
||||
|
||||
// 发送请求
|
||||
var response = await request.send();
|
||||
var responseBody = await response.stream.bytesToString();
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final jsonData = json.decode(responseBody);
|
||||
return YOLOAnalysisResult.fromJson(jsonData);
|
||||
} else {
|
||||
return YOLOAnalysisResult(
|
||||
objects: [],
|
||||
error: 'API请求失败: ${response.statusCode}',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// 如果API不可用,返回模拟数据(用于测试)
|
||||
return _getMockAnalysisResult();
|
||||
}
|
||||
}
|
||||
|
||||
// 分析多张图片
|
||||
Future<Map<String, YOLOAnalysisResult>> analyzeImages(List<String> imagePaths) async {
|
||||
Map<String, YOLOAnalysisResult> results = {};
|
||||
|
||||
for (String path in imagePaths) {
|
||||
results[path] = await analyzeImage(path);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// 获取模拟分析结果(用于测试,当API不可用时)
|
||||
YOLOAnalysisResult _getMockAnalysisResult() {
|
||||
return YOLOAnalysisResult(
|
||||
objects: [
|
||||
DetectedObject(
|
||||
label: 'person',
|
||||
confidence: 0.95,
|
||||
bbox: BoundingBox(x: 100, y: 100, width: 200, height: 300),
|
||||
),
|
||||
DetectedObject(
|
||||
label: 'chair',
|
||||
confidence: 0.87,
|
||||
bbox: BoundingBox(x: 300, y: 250, width: 150, height: 200),
|
||||
),
|
||||
],
|
||||
error: '使用模拟数据(YOLO API未配置或不可用)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user