deploy model Yolov5 to camera, tts, and bounding box
This commit is contained in:
parent
04383690d1
commit
61264de620
|
|
@ -1,10 +1,12 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.CAMERA"/>
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||||
<application
|
<application
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,36 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:camera/camera.dart';
|
import 'package:camera/camera.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../services/tts_service.dart';
|
||||||
|
import '../services/yolo_detection_service.dart';
|
||||||
|
|
||||||
class CameraScreen extends StatefulWidget {
|
class CameraScreen extends StatefulWidget {
|
||||||
|
const CameraScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_CameraScreenState createState() => _CameraScreenState();
|
State<CameraScreen> createState() => _CameraScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CameraScreenState extends State<CameraScreen> {
|
class _CameraScreenState extends State<CameraScreen> {
|
||||||
CameraController? controller;
|
CameraController? controller;
|
||||||
List<CameraDescription>? cameras;
|
List<CameraDescription>? cameras;
|
||||||
|
|
||||||
|
final YoloDetectionService _detectionService = YoloDetectionService();
|
||||||
|
final TTSService _tts = TTSService();
|
||||||
|
|
||||||
bool isRecording = false;
|
bool isRecording = false;
|
||||||
|
bool isDetecting = false;
|
||||||
|
String statusMessage = "Tekan tombol rekam untuk mulai deteksi";
|
||||||
|
List<YoloDetection> detectedObjects = const [];
|
||||||
|
Size? detectionFrameSize;
|
||||||
|
Timer? _detectionTimer;
|
||||||
|
String _lastSpokenMessage = "";
|
||||||
|
DateTime? _lastSpokenAt;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -19,49 +39,198 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initCamera() async {
|
Future<void> initCamera() async {
|
||||||
cameras = await availableCameras();
|
try {
|
||||||
controller = CameraController(
|
cameras = await availableCameras();
|
||||||
cameras![0],
|
|
||||||
ResolutionPreset.high,
|
|
||||||
);
|
|
||||||
|
|
||||||
await controller!.initialize();
|
if (cameras == null || cameras!.isEmpty) {
|
||||||
setState(() {});
|
setState(() {
|
||||||
|
statusMessage = "Kamera tidak ditemukan";
|
||||||
|
});
|
||||||
|
await _tts.speak(statusMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
controller = CameraController(
|
||||||
|
cameras![0],
|
||||||
|
ResolutionPreset.high,
|
||||||
|
enableAudio: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await controller!.initialize();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {});
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
statusMessage = "Gagal membuka kamera: $error";
|
||||||
|
});
|
||||||
|
await _tts.speak("Gagal membuka kamera");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔴 START RECORD
|
|
||||||
Future<void> startRecording() async {
|
Future<void> startRecording() async {
|
||||||
if (!controller!.value.isRecordingVideo) {
|
final CameraController? cameraController = controller;
|
||||||
await controller!.startVideoRecording();
|
if (cameraController == null ||
|
||||||
|
!cameraController.value.isInitialized ||
|
||||||
|
isRecording) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
isRecording = true;
|
||||||
|
statusMessage = "Deteksi dimulai";
|
||||||
|
detectedObjects = const [];
|
||||||
|
detectionFrameSize = null;
|
||||||
|
});
|
||||||
|
await _tts.speak("Deteksi dimulai");
|
||||||
|
|
||||||
|
await _detectCurrentFrame();
|
||||||
|
_detectionTimer = Timer.periodic(const Duration(seconds: 3), (_) {
|
||||||
|
if (mounted && isRecording && !isDetecting) {
|
||||||
|
_detectCurrentFrame();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stopRecording() async {
|
||||||
|
_detectionTimer?.cancel();
|
||||||
|
_detectionTimer = null;
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
isRecording = false;
|
||||||
|
isDetecting = false;
|
||||||
|
statusMessage = "Deteksi dihentikan";
|
||||||
|
detectedObjects = const [];
|
||||||
|
detectionFrameSize = null;
|
||||||
|
});
|
||||||
|
await _tts.speak("Deteksi dihentikan");
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _detectCurrentFrame() async {
|
||||||
|
final CameraController? cameraController = controller;
|
||||||
|
if (cameraController == null ||
|
||||||
|
!cameraController.value.isInitialized ||
|
||||||
|
cameraController.value.isTakingPicture ||
|
||||||
|
isDetecting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
isDetecting = true;
|
||||||
|
statusMessage = "Mengambil frame kamera...";
|
||||||
|
});
|
||||||
|
|
||||||
|
File? capturedFile;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final XFile frame = await cameraController.takePicture();
|
||||||
|
capturedFile = File(frame.path);
|
||||||
|
final Size capturedImageSize = await _readImageSize(capturedFile);
|
||||||
|
|
||||||
|
if (!mounted || !isRecording) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
isRecording = true;
|
statusMessage = "Mengirim frame ke YOLOv5...";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final YoloDetectionResponse response =
|
||||||
|
await _detectionService.detectFrame(capturedFile);
|
||||||
|
|
||||||
|
if (!mounted || !isRecording) return;
|
||||||
|
setState(() {
|
||||||
|
detectedObjects = response.objects;
|
||||||
|
detectionFrameSize = capturedImageSize;
|
||||||
|
statusMessage = response.message;
|
||||||
|
});
|
||||||
|
|
||||||
|
await _announceDetection(response);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted || !isRecording) return;
|
||||||
|
const String message = "Gagal memproses frame kamera";
|
||||||
|
setState(() {
|
||||||
|
statusMessage = "$message: $error";
|
||||||
|
});
|
||||||
|
await _speakOnce(message);
|
||||||
|
} finally {
|
||||||
|
if (capturedFile != null && await capturedFile.exists()) {
|
||||||
|
try {
|
||||||
|
await capturedFile.delete();
|
||||||
|
} catch (_) {
|
||||||
|
// File sementara bisa saja sudah dibersihkan oleh sistem.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
isDetecting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⏹ STOP RECORD
|
Future<Size> _readImageSize(File imageFile) async {
|
||||||
Future<void> stopRecording() async {
|
final Uint8List bytes = await imageFile.readAsBytes();
|
||||||
if (controller!.value.isRecordingVideo) {
|
final ui.Codec codec = await ui.instantiateImageCodec(bytes);
|
||||||
await controller!.stopVideoRecording();
|
final ui.FrameInfo frameInfo = await codec.getNextFrame();
|
||||||
setState(() {
|
return Size(
|
||||||
isRecording = false;
|
frameInfo.image.width.toDouble(),
|
||||||
});
|
frameInfo.image.height.toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _announceDetection(YoloDetectionResponse response) async {
|
||||||
|
if (!response.isSuccess) {
|
||||||
|
await _speakOnce(response.message);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (response.objects.isEmpty) {
|
||||||
|
await _speakOnce("Tidak ada objek terdeteksi");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, int> objectCounts = {};
|
||||||
|
for (final YoloDetection object in response.objects) {
|
||||||
|
objectCounts[object.name] = (objectCounts[object.name] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String objectSummary = objectCounts.entries
|
||||||
|
.map(
|
||||||
|
(entry) => entry.value > 1 ? "${entry.value} ${entry.key}" : entry.key,
|
||||||
|
)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
await _speakOnce("Terdeteksi $objectSummary");
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _speakOnce(String message) async {
|
||||||
|
final DateTime now = DateTime.now();
|
||||||
|
final bool canRepeat = _lastSpokenAt == null ||
|
||||||
|
now.difference(_lastSpokenAt!) > const Duration(seconds: 5);
|
||||||
|
|
||||||
|
if (message == _lastSpokenMessage && !canRepeat) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastSpokenMessage = message;
|
||||||
|
_lastSpokenAt = now;
|
||||||
|
await _tts.speak(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_detectionTimer?.cancel();
|
||||||
controller?.dispose();
|
controller?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget buildCameraPreview() {
|
Widget buildCameraPreview() {
|
||||||
final previewSize = controller?.value.previewSize;
|
final Size? previewSize = controller?.value.previewSize;
|
||||||
if (previewSize == null) {
|
if (previewSize == null) {
|
||||||
return CameraPreview(controller!);
|
return CameraPreview(controller!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kamera biasanya memberikan rasio landscape, ubah ke portrait 9:16
|
|
||||||
final double previewAspectRatio = previewSize.height / previewSize.width;
|
final double previewAspectRatio = previewSize.height / previewSize.width;
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
|
|
@ -88,19 +257,46 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (controller == null || !controller!.value.isInitialized) {
|
if (controller == null || !controller!.value.isInitialized) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Center(child: CircularProgressIndicator()),
|
body: Center(
|
||||||
|
child: statusMessage == "Tekan tombol rekam untuk mulai deteksi"
|
||||||
|
? const CircularProgressIndicator()
|
||||||
|
: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
statusMessage,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
// 📷 PREVIEW KAMERA (FULL SCREEN)
|
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: buildCameraPreview(),
|
child: buildCameraPreview(),
|
||||||
),
|
),
|
||||||
|
Positioned.fill(
|
||||||
// 🔙 TOMBOL KEMBALI (KANAN BAWAH)
|
child: IgnorePointer(
|
||||||
|
child: _BoundingBoxOverlay(
|
||||||
|
imageSize: detectionFrameSize,
|
||||||
|
objects: detectedObjects,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 48,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
child: _DetectionStatusPanel(
|
||||||
|
isRecording: isRecording,
|
||||||
|
isDetecting: isDetecting,
|
||||||
|
statusMessage: statusMessage,
|
||||||
|
objects: detectedObjects,
|
||||||
|
),
|
||||||
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 40,
|
bottom: 40,
|
||||||
right: 30,
|
right: 30,
|
||||||
|
|
@ -112,12 +308,10 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
child: Icon(Icons.arrow_back),
|
child: const Icon(Icons.arrow_back),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 🔘 TOMBOL RECORD (TENGAH BAWAH)
|
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 30,
|
bottom: 30,
|
||||||
left: MediaQuery.of(context).size.width / 2 - 35,
|
left: MediaQuery.of(context).size.width / 2 - 35,
|
||||||
|
|
@ -153,4 +347,243 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _BoundingBoxOverlay extends StatelessWidget {
|
||||||
|
const _BoundingBoxOverlay({
|
||||||
|
required this.imageSize,
|
||||||
|
required this.objects,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Size? imageSize;
|
||||||
|
final List<YoloDetection> objects;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (imageSize == null || objects.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return CustomPaint(
|
||||||
|
painter: _BoundingBoxPainter(
|
||||||
|
imageSize: imageSize!,
|
||||||
|
objects: objects,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BoundingBoxPainter extends CustomPainter {
|
||||||
|
_BoundingBoxPainter({
|
||||||
|
required this.imageSize,
|
||||||
|
required this.objects,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Size imageSize;
|
||||||
|
final List<YoloDetection> objects;
|
||||||
|
|
||||||
|
static const double _previewAspectRatio = 9 / 16;
|
||||||
|
static const List<Color> _boxColors = [
|
||||||
|
Color(0xFF00E676),
|
||||||
|
Color(0xFFFFD600),
|
||||||
|
Color(0xFF40C4FF),
|
||||||
|
Color(0xFFFF4081),
|
||||||
|
Color(0xFFFF9100),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
if (imageSize.width <= 0 || imageSize.height <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Rect previewRect = _centeredAspectRect(size, _previewAspectRatio);
|
||||||
|
final double scale = _coverScale(imageSize, previewRect.size);
|
||||||
|
final Size scaledImageSize = Size(
|
||||||
|
imageSize.width * scale,
|
||||||
|
imageSize.height * scale,
|
||||||
|
);
|
||||||
|
final Offset imageOffset = Offset(
|
||||||
|
previewRect.left + (previewRect.width - scaledImageSize.width) / 2,
|
||||||
|
previewRect.top + (previewRect.height - scaledImageSize.height) / 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
canvas.save();
|
||||||
|
canvas.clipRect(previewRect);
|
||||||
|
|
||||||
|
for (int index = 0; index < objects.length; index++) {
|
||||||
|
final YoloDetection object = objects[index];
|
||||||
|
final Color color = _boxColors[index % _boxColors.length];
|
||||||
|
final Rect box = Rect.fromLTRB(
|
||||||
|
imageOffset.dx + object.xmin * scale,
|
||||||
|
imageOffset.dy + object.ymin * scale,
|
||||||
|
imageOffset.dx + object.xmax * scale,
|
||||||
|
imageOffset.dy + object.ymax * scale,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (box.width <= 0 || box.height <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawBox(canvas, previewRect, box, color, object);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawBox(
|
||||||
|
Canvas canvas,
|
||||||
|
Rect previewRect,
|
||||||
|
Rect box,
|
||||||
|
Color color,
|
||||||
|
YoloDetection object,
|
||||||
|
) {
|
||||||
|
final Paint shadowPaint = Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 5
|
||||||
|
..color = Colors.black.withOpacity(0.55);
|
||||||
|
final Paint boxPaint = Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 3
|
||||||
|
..color = color;
|
||||||
|
|
||||||
|
canvas.drawRect(box, shadowPaint);
|
||||||
|
canvas.drawRect(box, boxPaint);
|
||||||
|
|
||||||
|
final String label = "${object.name} ${(object.confidence * 100).round()}%";
|
||||||
|
final TextPainter textPainter = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: label,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.black,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
)..layout(maxWidth: previewRect.width - 16);
|
||||||
|
|
||||||
|
final double labelWidth = textPainter.width + 12;
|
||||||
|
final double labelHeight = textPainter.height + 8;
|
||||||
|
double labelLeft = box.left;
|
||||||
|
double labelTop = box.top - labelHeight;
|
||||||
|
|
||||||
|
if (labelTop < previewRect.top) {
|
||||||
|
labelTop = box.top;
|
||||||
|
}
|
||||||
|
if (labelLeft + labelWidth > previewRect.right) {
|
||||||
|
labelLeft = previewRect.right - labelWidth;
|
||||||
|
}
|
||||||
|
labelLeft =
|
||||||
|
labelLeft.clamp(previewRect.left, previewRect.right - labelWidth).toDouble();
|
||||||
|
|
||||||
|
final RRect labelBackground = RRect.fromRectAndRadius(
|
||||||
|
Rect.fromLTWH(labelLeft, labelTop, labelWidth, labelHeight),
|
||||||
|
const Radius.circular(6),
|
||||||
|
);
|
||||||
|
|
||||||
|
canvas.drawRRect(
|
||||||
|
labelBackground,
|
||||||
|
Paint()..color = color.withOpacity(0.92),
|
||||||
|
);
|
||||||
|
textPainter.paint(
|
||||||
|
canvas,
|
||||||
|
Offset(labelLeft + 6, labelTop + 4),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Rect _centeredAspectRect(Size containerSize, double aspectRatio) {
|
||||||
|
final double containerRatio = containerSize.width / containerSize.height;
|
||||||
|
|
||||||
|
if (containerRatio > aspectRatio) {
|
||||||
|
final double width = containerSize.height * aspectRatio;
|
||||||
|
final double left = (containerSize.width - width) / 2;
|
||||||
|
return Rect.fromLTWH(left, 0, width, containerSize.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
final double height = containerSize.width / aspectRatio;
|
||||||
|
final double top = (containerSize.height - height) / 2;
|
||||||
|
return Rect.fromLTWH(0, top, containerSize.width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _coverScale(Size source, Size destination) {
|
||||||
|
final double scaleX = destination.width / source.width;
|
||||||
|
final double scaleY = destination.height / source.height;
|
||||||
|
return scaleX > scaleY ? scaleX : scaleY;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _BoundingBoxPainter oldDelegate) {
|
||||||
|
return oldDelegate.imageSize != imageSize || oldDelegate.objects != objects;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DetectionStatusPanel extends StatelessWidget {
|
||||||
|
const _DetectionStatusPanel({
|
||||||
|
required this.isRecording,
|
||||||
|
required this.isDetecting,
|
||||||
|
required this.statusMessage,
|
||||||
|
required this.objects,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool isRecording;
|
||||||
|
final bool isDetecting;
|
||||||
|
final String statusMessage;
|
||||||
|
final List<YoloDetection> objects;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final List<YoloDetection> visibleObjects = objects.take(4).toList();
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.72),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isRecording ? Icons.radio_button_checked : Icons.videocam,
|
||||||
|
color: isRecording ? Colors.redAccent : Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
isDetecting ? "YOLOv5 sedang berjalan..." : statusMessage,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (visibleObjects.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
for (final YoloDetection object in visibleObjects)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
"${object.name} - ${(object.confidence * 100).round()}%",
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ class ApiConfig {
|
||||||
static const bool useAndroidEmulator = false;
|
static const bool useAndroidEmulator = false;
|
||||||
|
|
||||||
// Ganti IP ini dengan IP laptop/PC Anda saat menggunakan HP fisik (ADB).
|
// Ganti IP ini dengan IP laptop/PC Anda saat menggunakan HP fisik (ADB).
|
||||||
static const String deviceHost = "192.168.0.31:8000";
|
static const String deviceHost = "192.168.18.14:8000";
|
||||||
|
|
||||||
static String get baseUrl {
|
static String get baseUrl {
|
||||||
if (Platform.isAndroid && useAndroidEmulator) {
|
if (Platform.isAndroid && useAndroidEmulator) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import 'api_config.dart';
|
||||||
|
|
||||||
|
class YoloDetection {
|
||||||
|
const YoloDetection({
|
||||||
|
required this.name,
|
||||||
|
required this.confidence,
|
||||||
|
required this.xmin,
|
||||||
|
required this.ymin,
|
||||||
|
required this.xmax,
|
||||||
|
required this.ymax,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final double confidence;
|
||||||
|
final int xmin;
|
||||||
|
final int ymin;
|
||||||
|
final int xmax;
|
||||||
|
final int ymax;
|
||||||
|
|
||||||
|
factory YoloDetection.fromJson(Map<String, dynamic> json) {
|
||||||
|
return YoloDetection(
|
||||||
|
name: json["name"]?.toString() ?? "unknown",
|
||||||
|
confidence: (json["confidence"] as num?)?.toDouble() ?? 0,
|
||||||
|
xmin: (json["xmin"] as num?)?.toInt() ?? 0,
|
||||||
|
ymin: (json["ymin"] as num?)?.toInt() ?? 0,
|
||||||
|
xmax: (json["xmax"] as num?)?.toInt() ?? 0,
|
||||||
|
ymax: (json["ymax"] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class YoloDetectionResponse {
|
||||||
|
const YoloDetectionResponse({
|
||||||
|
required this.status,
|
||||||
|
required this.message,
|
||||||
|
required this.objects,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String status;
|
||||||
|
final String message;
|
||||||
|
final List<YoloDetection> objects;
|
||||||
|
|
||||||
|
bool get isSuccess => status == "success";
|
||||||
|
|
||||||
|
factory YoloDetectionResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
final Object? rawObjects = json["objects"];
|
||||||
|
final List<YoloDetection> objects = rawObjects is List
|
||||||
|
? rawObjects
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((object) => YoloDetection.fromJson(
|
||||||
|
Map<String, dynamic>.from(object),
|
||||||
|
))
|
||||||
|
.toList()
|
||||||
|
: const [];
|
||||||
|
|
||||||
|
return YoloDetectionResponse(
|
||||||
|
status: json["status"]?.toString() ?? "error",
|
||||||
|
message: json["message"]?.toString() ?? "Respons server tidak valid",
|
||||||
|
objects: objects,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory YoloDetectionResponse.error(String message) {
|
||||||
|
return YoloDetectionResponse(
|
||||||
|
status: "error",
|
||||||
|
message: message,
|
||||||
|
objects: const [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class YoloDetectionService {
|
||||||
|
static const Duration _timeout = Duration(seconds: 30);
|
||||||
|
|
||||||
|
Future<YoloDetectionResponse> detectFrame(File imageFile) async {
|
||||||
|
final Uri url = Uri.parse("${ApiConfig.baseUrl}/detect-frame");
|
||||||
|
|
||||||
|
try {
|
||||||
|
final http.MultipartRequest request = http.MultipartRequest("POST", url);
|
||||||
|
request.files.add(
|
||||||
|
await http.MultipartFile.fromPath("file", imageFile.path),
|
||||||
|
);
|
||||||
|
|
||||||
|
final http.StreamedResponse streamedResponse =
|
||||||
|
await request.send().timeout(_timeout);
|
||||||
|
final http.Response response =
|
||||||
|
await http.Response.fromStream(streamedResponse);
|
||||||
|
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
return YoloDetectionResponse.error(
|
||||||
|
"Server mengembalikan status ${response.statusCode}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Object? decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
return YoloDetectionResponse.error("Format respons server tidak valid");
|
||||||
|
}
|
||||||
|
|
||||||
|
return YoloDetectionResponse.fromJson(decoded);
|
||||||
|
} on SocketException {
|
||||||
|
return YoloDetectionResponse.error(
|
||||||
|
"Gagal terhubung ke server. Pastikan API YOLOv5 sudah berjalan.",
|
||||||
|
);
|
||||||
|
} on TimeoutException {
|
||||||
|
return YoloDetectionResponse.error("Koneksi ke YOLOv5 terlalu lama");
|
||||||
|
} on FormatException {
|
||||||
|
return YoloDetectionResponse.error("Respons server bukan JSON valid");
|
||||||
|
} on http.ClientException {
|
||||||
|
return YoloDetectionResponse.error("Koneksi ke server terputus");
|
||||||
|
} catch (error) {
|
||||||
|
return YoloDetectionResponse.error("Gagal deteksi objek: $error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue