deploy model Yolov5 to camera, tts, and bounding box

This commit is contained in:
Samsularifin01 2026-05-19 00:11:19 +07:00
parent 04383690d1
commit 61264de620
4 changed files with 588 additions and 32 deletions

View File

@ -1,10 +1,12 @@
<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.RECORD_AUDIO"/>
<application
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"

View File

@ -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:flutter/material.dart';
import '../services/tts_service.dart';
import '../services/yolo_detection_service.dart';
class CameraScreen extends StatefulWidget {
const CameraScreen({super.key});
@override
_CameraScreenState createState() => _CameraScreenState();
State<CameraScreen> createState() => _CameraScreenState();
}
class _CameraScreenState extends State<CameraScreen> {
CameraController? controller;
List<CameraDescription>? cameras;
final YoloDetectionService _detectionService = YoloDetectionService();
final TTSService _tts = TTSService();
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
void initState() {
@ -19,49 +39,198 @@ class _CameraScreenState extends State<CameraScreen> {
}
Future<void> initCamera() async {
cameras = await availableCameras();
controller = CameraController(
cameras![0],
ResolutionPreset.high,
);
try {
cameras = await availableCameras();
await controller!.initialize();
setState(() {});
if (cameras == null || cameras!.isEmpty) {
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 {
if (!controller!.value.isRecordingVideo) {
await controller!.startVideoRecording();
final CameraController? cameraController = controller;
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(() {
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<void> stopRecording() async {
if (controller!.value.isRecordingVideo) {
await controller!.stopVideoRecording();
setState(() {
isRecording = false;
});
Future<Size> _readImageSize(File imageFile) async {
final Uint8List bytes = await imageFile.readAsBytes();
final ui.Codec codec = await ui.instantiateImageCodec(bytes);
final ui.FrameInfo frameInfo = await codec.getNextFrame();
return Size(
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
void dispose() {
_detectionTimer?.cancel();
controller?.dispose();
super.dispose();
}
Widget buildCameraPreview() {
final previewSize = controller?.value.previewSize;
final Size? previewSize = controller?.value.previewSize;
if (previewSize == null) {
return CameraPreview(controller!);
}
// Kamera biasanya memberikan rasio landscape, ubah ke portrait 9:16
final double previewAspectRatio = previewSize.height / previewSize.width;
return Center(
@ -88,19 +257,46 @@ class _CameraScreenState extends State<CameraScreen> {
Widget build(BuildContext context) {
if (controller == null || !controller!.value.isInitialized) {
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(
body: Stack(
children: [
// 📷 PREVIEW KAMERA (FULL SCREEN)
Positioned.fill(
child: buildCameraPreview(),
),
// 🔙 TOMBOL KEMBALI (KANAN BAWAH)
Positioned.fill(
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(
bottom: 40,
right: 30,
@ -112,12 +308,10 @@ class _CameraScreenState extends State<CameraScreen> {
onPressed: () {
Navigator.pop(context);
},
child: Icon(Icons.arrow_back),
child: const Icon(Icons.arrow_back),
),
),
),
// 🔘 TOMBOL RECORD (TENGAH BAWAH)
Positioned(
bottom: 30,
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,
),
),
),
],
],
),
);
}
}

View File

@ -5,7 +5,7 @@ class ApiConfig {
static const bool useAndroidEmulator = false;
// 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 {
if (Platform.isAndroid && useAndroidEmulator) {

View File

@ -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");
}
}
}