feat: integrate YOLO runtime, update screens, and sync docs
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../core/constants/app_constants.dart';
|
||||
import '../features/activity_log/activity_log_screen.dart' as activity;
|
||||
import '../features/notifications/notification_screen.dart' as notifications;
|
||||
import '../features/screens.dart';
|
||||
import '../shared/widgets/app_shells.dart';
|
||||
|
||||
@@ -38,10 +40,10 @@ final GoRouter appRouter = GoRouter(
|
||||
GoRoute(path: '/user/sos', builder: (_, __) => const SosScreen()),
|
||||
GoRoute(
|
||||
path: '/user/activity',
|
||||
builder: (_, __) => const ActivityLogScreen()),
|
||||
builder: (_, __) => const activity.ActivityLogScreen()),
|
||||
GoRoute(
|
||||
path: '/user/notifications',
|
||||
builder: (_, __) => const NotificationScreen()),
|
||||
builder: (_, __) => const notifications.NotificationScreen()),
|
||||
GoRoute(
|
||||
path: '/user/navigation',
|
||||
builder: (_, __) => const NavigationModeScreen()),
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../constants/app_constants.dart';
|
||||
import 'obstacle_analyzer.dart';
|
||||
import 'yolo_runtime.dart';
|
||||
|
||||
class YoloDetector {
|
||||
final ObstacleAnalyzer _analyzer;
|
||||
List<String> _labels = const [];
|
||||
YoloRuntime? _runtime;
|
||||
List<int> _inputShape = const [];
|
||||
List<int> _outputShape = const [];
|
||||
YoloTensorType? _inputType;
|
||||
YoloTensorType? _outputType;
|
||||
bool _ready = false;
|
||||
String? _lastError;
|
||||
|
||||
YoloDetector(this._analyzer);
|
||||
|
||||
bool get isReady => _ready;
|
||||
bool get isReady => _ready && _runtime != null;
|
||||
String? get lastError => _lastError;
|
||||
|
||||
Future<void> init() async {
|
||||
dispose();
|
||||
_lastError = null;
|
||||
try {
|
||||
_labels = (await rootBundle.loadString(AppConstants.yoloLabelsPath))
|
||||
.split('\n')
|
||||
@@ -24,24 +37,489 @@ class YoloDetector {
|
||||
_ready = false;
|
||||
return;
|
||||
}
|
||||
await rootBundle.load(await AppConstants.getSelectedYoloModelPath());
|
||||
_runtime = await YoloRuntime.load(
|
||||
await AppConstants.getSelectedYoloModelPath(),
|
||||
);
|
||||
_inputShape = _runtime!.inputShape;
|
||||
_outputShape = _runtime!.outputShape;
|
||||
_inputType = _runtime!.inputType;
|
||||
_outputType = _runtime!.outputType;
|
||||
_ready = true;
|
||||
} catch (e) {
|
||||
_lastError = e.toString();
|
||||
debugPrint('YOLO fallback mode: $e');
|
||||
_ready = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<DetectionResult?> detectFallback() async {
|
||||
if (_ready) {
|
||||
// Full tensor pre/post-processing belongs here once yolov8n.tflite is present.
|
||||
// The app keeps a deterministic fallback so demo flows remain testable.
|
||||
Future<DetectionResult?> detect(
|
||||
CameraImage image, {
|
||||
double confidenceThreshold = 0.45,
|
||||
}) async {
|
||||
if (!isReady) return detectFallback();
|
||||
try {
|
||||
final input = _buildCameraInput(image);
|
||||
final output = Uint8List(_runtime!.outputByteLength);
|
||||
_runtime!.run(input, output);
|
||||
final detections = _decodeDetections(output);
|
||||
final filtered =
|
||||
_analyzer.filterByConfidence(detections, confidenceThreshold);
|
||||
return _analyzer.prioritize(filtered);
|
||||
} catch (e) {
|
||||
_lastError = e.toString();
|
||||
debugPrint('YOLO inference fallback: $e');
|
||||
return detectFallback();
|
||||
}
|
||||
}
|
||||
|
||||
Future<DetectionResult?> detectSynthetic({
|
||||
double confidenceThreshold = 0.25,
|
||||
}) async {
|
||||
if (!isReady) return detectFallback();
|
||||
try {
|
||||
final input = _buildSyntheticInput();
|
||||
final output = Uint8List(_runtime!.outputByteLength);
|
||||
_runtime!.run(input, output);
|
||||
final detections = _decodeDetections(output);
|
||||
final filtered =
|
||||
_analyzer.filterByConfidence(detections, confidenceThreshold);
|
||||
return _analyzer.prioritize(filtered) ?? detectFallback();
|
||||
} catch (e) {
|
||||
_lastError = e.toString();
|
||||
debugPrint('YOLO synthetic fallback: $e');
|
||||
return detectFallback();
|
||||
}
|
||||
}
|
||||
|
||||
Future<DetectionResult?> detectFallback() async {
|
||||
final label = _labels.isNotEmpty ? _labels.first : 'person';
|
||||
return _analyzer.analyzeFallback(label: label);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_runtime?.close();
|
||||
_runtime = null;
|
||||
_inputShape = const [];
|
||||
_outputShape = const [];
|
||||
_inputType = null;
|
||||
_outputType = null;
|
||||
_ready = false;
|
||||
}
|
||||
|
||||
Uint8List _buildCameraInput(CameraImage image) {
|
||||
final inputInfo = _inputInfo();
|
||||
final bytes = Uint8List(inputInfo.byteLength);
|
||||
final data = ByteData.view(bytes.buffer);
|
||||
|
||||
for (var y = 0; y < inputInfo.height; y++) {
|
||||
final srcY = (y * image.height / inputInfo.height).floor();
|
||||
for (var x = 0; x < inputInfo.width; x++) {
|
||||
final srcX = (x * image.width / inputInfo.width).floor();
|
||||
final rgb = _rgbAt(image, srcX, srcY);
|
||||
_writeInputValue(data, bytes, inputInfo, x, y, 0, (rgb >> 16) & 0xFF);
|
||||
_writeInputValue(data, bytes, inputInfo, x, y, 1, (rgb >> 8) & 0xFF);
|
||||
_writeInputValue(data, bytes, inputInfo, x, y, 2, rgb & 0xFF);
|
||||
}
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
Uint8List _buildSyntheticInput() {
|
||||
final inputInfo = _inputInfo();
|
||||
final bytes = Uint8List(inputInfo.byteLength);
|
||||
final data = ByteData.view(bytes.buffer);
|
||||
|
||||
for (var y = 0; y < inputInfo.height; y++) {
|
||||
for (var x = 0; x < inputInfo.width; x++) {
|
||||
final shade = ((x / inputInfo.width) * 64 + 96).round();
|
||||
_writeInputValue(data, bytes, inputInfo, x, y, 0, shade);
|
||||
_writeInputValue(data, bytes, inputInfo, x, y, 1, shade);
|
||||
_writeInputValue(data, bytes, inputInfo, x, y, 2, shade);
|
||||
}
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
_InputInfo _inputInfo() {
|
||||
if (_inputShape.length != 4) {
|
||||
throw StateError('Unsupported YOLO input shape: $_inputShape');
|
||||
}
|
||||
final type = _inputType;
|
||||
if (type == null) throw StateError('YOLO input type is unavailable');
|
||||
|
||||
final channelsLast = _inputShape.last == 3;
|
||||
final channelsFirst = _inputShape[1] == 3;
|
||||
if (!channelsLast && !channelsFirst) {
|
||||
throw StateError('Unsupported YOLO channel layout: $_inputShape');
|
||||
}
|
||||
if (type != YoloTensorType.float32 &&
|
||||
type != YoloTensorType.uint8 &&
|
||||
type != YoloTensorType.int8) {
|
||||
throw StateError('Unsupported YOLO input type: $type');
|
||||
}
|
||||
|
||||
final height = channelsLast ? _inputShape[1] : _inputShape[2];
|
||||
final width = channelsLast ? _inputShape[2] : _inputShape[3];
|
||||
final bytesPerValue = type == YoloTensorType.float32 ? 4 : 1;
|
||||
return _InputInfo(
|
||||
width: width,
|
||||
height: height,
|
||||
channelsLast: channelsLast,
|
||||
type: type,
|
||||
bytesPerValue: bytesPerValue,
|
||||
);
|
||||
}
|
||||
|
||||
void _writeInputValue(
|
||||
ByteData data,
|
||||
Uint8List bytes,
|
||||
_InputInfo info,
|
||||
int x,
|
||||
int y,
|
||||
int channel,
|
||||
int value,
|
||||
) {
|
||||
final elementIndex = info.channelsLast
|
||||
? ((y * info.width + x) * 3 + channel)
|
||||
: (channel * info.width * info.height + y * info.width + x);
|
||||
final byteOffset = elementIndex * info.bytesPerValue;
|
||||
|
||||
if (info.type == YoloTensorType.float32) {
|
||||
data.setFloat32(byteOffset, value / 255.0, Endian.little);
|
||||
} else if (info.type == YoloTensorType.uint8) {
|
||||
bytes[byteOffset] = value;
|
||||
} else if (info.type == YoloTensorType.int8) {
|
||||
data.setInt8(byteOffset, value - 128);
|
||||
}
|
||||
}
|
||||
|
||||
int _rgbAt(CameraImage image, int x, int y) {
|
||||
final group = image.format.group;
|
||||
if (group == ImageFormatGroup.bgra8888) {
|
||||
final plane = image.planes.first;
|
||||
final index = y * plane.bytesPerRow + x * 4;
|
||||
if (index + 2 >= plane.bytes.length) return 0;
|
||||
final b = plane.bytes[index];
|
||||
final g = plane.bytes[index + 1];
|
||||
final r = plane.bytes[index + 2];
|
||||
return (r << 16) | (g << 8) | b;
|
||||
}
|
||||
|
||||
if (image.planes.length < 3) {
|
||||
final plane = image.planes.first;
|
||||
final index = math.min(y * plane.bytesPerRow + x, plane.bytes.length - 1);
|
||||
final luma = plane.bytes[index];
|
||||
return (luma << 16) | (luma << 8) | luma;
|
||||
}
|
||||
|
||||
final yPlane = image.planes[0];
|
||||
final uPlane = image.planes[1];
|
||||
final vPlane = image.planes[2];
|
||||
|
||||
final yIndex =
|
||||
math.min(y * yPlane.bytesPerRow + x, yPlane.bytes.length - 1);
|
||||
final uvX = x ~/ 2;
|
||||
final uvY = y ~/ 2;
|
||||
final uvPixelStride = uPlane.bytesPerPixel ?? 1;
|
||||
final uvIndex = math.min(
|
||||
uvY * uPlane.bytesPerRow + uvX * uvPixelStride,
|
||||
math.min(uPlane.bytes.length, vPlane.bytes.length) - 1,
|
||||
);
|
||||
|
||||
final yy = yPlane.bytes[yIndex].toDouble();
|
||||
final uu = uPlane.bytes[uvIndex].toDouble() - 128.0;
|
||||
final vv = vPlane.bytes[uvIndex].toDouble() - 128.0;
|
||||
|
||||
final r = _clampByte(yy + 1.402 * vv);
|
||||
final g = _clampByte(yy - 0.344136 * uu - 0.714136 * vv);
|
||||
final b = _clampByte(yy + 1.772 * uu);
|
||||
return (r << 16) | (g << 8) | b;
|
||||
}
|
||||
|
||||
int _clampByte(double value) => value.round().clamp(0, 255);
|
||||
|
||||
List<DetectionResult> _decodeDetections(Uint8List outputBuffer) {
|
||||
final values = _readOutput(outputBuffer);
|
||||
if (values.isEmpty || _outputShape.isEmpty) return const [];
|
||||
|
||||
final dims = _outputShape.where((d) => d > 0).toList();
|
||||
final effectiveShape =
|
||||
dims.isNotEmpty && dims.first == 1 ? dims.sublist(1) : dims;
|
||||
|
||||
if (effectiveShape.length == 2) {
|
||||
final a = effectiveShape[0];
|
||||
final b = effectiveShape[1];
|
||||
if (a >= 5 && b > a) {
|
||||
return _decodeFeatureFirst(values, a, b);
|
||||
}
|
||||
if (b >= 5) {
|
||||
return _decodeRowFirst(values, a, b);
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveShape.length == 3 && effectiveShape[0] == 1) {
|
||||
final a = effectiveShape[1];
|
||||
final b = effectiveShape[2];
|
||||
if (a >= 5 && b > a) {
|
||||
return _decodeFeatureFirst(values, a, b);
|
||||
}
|
||||
if (b >= 5) {
|
||||
return _decodeRowFirst(values, a, b);
|
||||
}
|
||||
}
|
||||
|
||||
return const [];
|
||||
}
|
||||
|
||||
List<double> _readOutput(Uint8List bytes) {
|
||||
final type = _outputType;
|
||||
if (type == null) return const [];
|
||||
final data = ByteData.view(bytes.buffer);
|
||||
|
||||
if (type == YoloTensorType.float32) {
|
||||
return [
|
||||
for (var i = 0; i < bytes.length; i += 4)
|
||||
data.getFloat32(i, Endian.little),
|
||||
];
|
||||
}
|
||||
if (type == YoloTensorType.uint8) {
|
||||
return [for (final byte in bytes) byte / 255.0];
|
||||
}
|
||||
if (type == YoloTensorType.int8) {
|
||||
return [for (var i = 0; i < bytes.length; i++) data.getInt8(i) / 127.0];
|
||||
}
|
||||
if (type == YoloTensorType.float16) {
|
||||
// Most exported YOLOv8 TFLite files use float32 output. Float16 is rare;
|
||||
// fallback keeps the app usable if an unsupported variant is selected.
|
||||
throw StateError('Float16 YOLO output is not supported yet');
|
||||
}
|
||||
throw StateError('Unsupported YOLO output type: $type');
|
||||
}
|
||||
|
||||
List<DetectionResult> _decodeFeatureFirst(
|
||||
List<double> values,
|
||||
int featureCount,
|
||||
int boxCount,
|
||||
) {
|
||||
final detections = <DetectionResult>[];
|
||||
final classStart = _classStart(featureCount);
|
||||
for (var i = 0; i < boxCount; i++) {
|
||||
final cx = values[i];
|
||||
final cy = values[boxCount + i];
|
||||
final w = values[2 * boxCount + i];
|
||||
final h = values[3 * boxCount + i];
|
||||
final scored = _bestClassFeatureFirst(values, featureCount, boxCount,
|
||||
classStart: classStart, boxIndex: i);
|
||||
if (scored == null) continue;
|
||||
detections.add(_fromCenterBox(
|
||||
label: scored.label,
|
||||
confidence: scored.score,
|
||||
cx: cx,
|
||||
cy: cy,
|
||||
width: w,
|
||||
height: h,
|
||||
));
|
||||
}
|
||||
return detections;
|
||||
}
|
||||
|
||||
List<DetectionResult> _decodeRowFirst(
|
||||
List<double> values,
|
||||
int rowCount,
|
||||
int featureCount,
|
||||
) {
|
||||
final detections = <DetectionResult>[];
|
||||
if (featureCount == 6 || featureCount == 7) {
|
||||
for (var row = 0; row < rowCount; row++) {
|
||||
final offset = row * featureCount;
|
||||
final score = values[offset + 4].clamp(0.0, 1.0);
|
||||
final classIndex = values[offset + 5].round();
|
||||
detections.add(_fromCornerBox(
|
||||
label: _labelFor(classIndex),
|
||||
confidence: score,
|
||||
left: values[offset],
|
||||
top: values[offset + 1],
|
||||
right: values[offset + 2],
|
||||
bottom: values[offset + 3],
|
||||
));
|
||||
}
|
||||
return detections;
|
||||
}
|
||||
|
||||
final classStart = _classStart(featureCount);
|
||||
for (var row = 0; row < rowCount; row++) {
|
||||
final offset = row * featureCount;
|
||||
final scored =
|
||||
_bestClassRow(values, offset, featureCount, classStart: classStart);
|
||||
if (scored == null) continue;
|
||||
detections.add(_fromCenterBox(
|
||||
label: scored.label,
|
||||
confidence: scored.score,
|
||||
cx: values[offset],
|
||||
cy: values[offset + 1],
|
||||
width: values[offset + 2],
|
||||
height: values[offset + 3],
|
||||
));
|
||||
}
|
||||
return detections;
|
||||
}
|
||||
|
||||
int _classStart(int featureCount) {
|
||||
if (featureCount == 85) return 5; // YOLOv5 COCO: xywh + obj + 80 cls
|
||||
if (_labels.isNotEmpty && featureCount == _labels.length + 5) return 5;
|
||||
return 4;
|
||||
}
|
||||
|
||||
_ScoredLabel? _bestClassFeatureFirst(
|
||||
List<double> values,
|
||||
int featureCount,
|
||||
int boxCount, {
|
||||
required int classStart,
|
||||
required int boxIndex,
|
||||
}) {
|
||||
final objectness = classStart == 5 ? values[4 * boxCount + boxIndex] : 1.0;
|
||||
var bestScore = 0.0;
|
||||
var bestClass = 0;
|
||||
for (var feature = classStart; feature < featureCount; feature++) {
|
||||
final score = objectness * values[feature * boxCount + boxIndex];
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestClass = feature - classStart;
|
||||
}
|
||||
}
|
||||
if (bestScore <= 0) return null;
|
||||
return _ScoredLabel(
|
||||
_labelFor(bestClass, classCount: featureCount - classStart),
|
||||
bestScore.clamp(0.0, 1.0),
|
||||
);
|
||||
}
|
||||
|
||||
_ScoredLabel? _bestClassRow(
|
||||
List<double> values,
|
||||
int offset,
|
||||
int featureCount, {
|
||||
required int classStart,
|
||||
}) {
|
||||
final objectness = classStart == 5 ? values[offset + 4] : 1.0;
|
||||
var bestScore = 0.0;
|
||||
var bestClass = 0;
|
||||
for (var feature = classStart; feature < featureCount; feature++) {
|
||||
final score = objectness * values[offset + feature];
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestClass = feature - classStart;
|
||||
}
|
||||
}
|
||||
if (bestScore <= 0) return null;
|
||||
return _ScoredLabel(
|
||||
_labelFor(bestClass, classCount: featureCount - classStart),
|
||||
bestScore.clamp(0.0, 1.0),
|
||||
);
|
||||
}
|
||||
|
||||
String _labelFor(int classIndex, {int? classCount}) {
|
||||
if (classCount != null && classCount > _labels.length) {
|
||||
return _cocoObstacleLabels[classIndex] ?? 'object';
|
||||
}
|
||||
if (classIndex >= 0 && classIndex < _labels.length) {
|
||||
return _labels[classIndex];
|
||||
}
|
||||
return 'object';
|
||||
}
|
||||
|
||||
DetectionResult _fromCenterBox({
|
||||
required String label,
|
||||
required double confidence,
|
||||
required double cx,
|
||||
required double cy,
|
||||
required double width,
|
||||
required double height,
|
||||
}) {
|
||||
return _fromCornerBox(
|
||||
label: label,
|
||||
confidence: confidence,
|
||||
left: cx - width / 2,
|
||||
top: cy - height / 2,
|
||||
right: cx + width / 2,
|
||||
bottom: cy + height / 2,
|
||||
);
|
||||
}
|
||||
|
||||
DetectionResult _fromCornerBox({
|
||||
required String label,
|
||||
required double confidence,
|
||||
required double left,
|
||||
required double top,
|
||||
required double right,
|
||||
required double bottom,
|
||||
}) {
|
||||
final inputInfo = _inputInfo();
|
||||
final normalized =
|
||||
[left, top, right, bottom].every((value) => value >= 0 && value <= 1.5);
|
||||
|
||||
final scaledLeft = normalized
|
||||
? left * ObstacleAnalyzer.frameWidth
|
||||
: left / inputInfo.width * ObstacleAnalyzer.frameWidth;
|
||||
final scaledRight = normalized
|
||||
? right * ObstacleAnalyzer.frameWidth
|
||||
: right / inputInfo.width * ObstacleAnalyzer.frameWidth;
|
||||
final scaledTop = normalized
|
||||
? top * ObstacleAnalyzer.frameHeight
|
||||
: top / inputInfo.height * ObstacleAnalyzer.frameHeight;
|
||||
final scaledBottom = normalized
|
||||
? bottom * ObstacleAnalyzer.frameHeight
|
||||
: bottom / inputInfo.height * ObstacleAnalyzer.frameHeight;
|
||||
|
||||
final box = BoundingBox(
|
||||
left: scaledLeft.clamp(0.0, ObstacleAnalyzer.frameWidth),
|
||||
top: scaledTop.clamp(0.0, ObstacleAnalyzer.frameHeight),
|
||||
right: scaledRight.clamp(0.0, ObstacleAnalyzer.frameWidth),
|
||||
bottom: scaledBottom.clamp(0.0, ObstacleAnalyzer.frameHeight),
|
||||
);
|
||||
|
||||
return DetectionResult(
|
||||
label: label,
|
||||
confidence: confidence,
|
||||
direction: _analyzer.analyzeDirection(box),
|
||||
estimatedDistance: _analyzer.estimateDistance(box),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Map<int, String> _cocoObstacleLabels = {
|
||||
0: 'person',
|
||||
1: 'bicycle',
|
||||
2: 'car',
|
||||
3: 'motorcycle',
|
||||
5: 'bus',
|
||||
7: 'truck',
|
||||
13: 'bench',
|
||||
56: 'chair',
|
||||
};
|
||||
|
||||
class _InputInfo {
|
||||
final int width;
|
||||
final int height;
|
||||
final bool channelsLast;
|
||||
final YoloTensorType type;
|
||||
final int bytesPerValue;
|
||||
|
||||
const _InputInfo({
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.channelsLast,
|
||||
required this.type,
|
||||
required this.bytesPerValue,
|
||||
});
|
||||
|
||||
int get byteLength => width * height * 3 * bytesPerValue;
|
||||
}
|
||||
|
||||
class _ScoredLabel {
|
||||
final String label;
|
||||
final double score;
|
||||
|
||||
const _ScoredLabel(this.label, this.score);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export 'yolo_runtime_stub.dart' if (dart.library.io) 'yolo_runtime_native.dart';
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:tflite_flutter/tflite_flutter.dart';
|
||||
|
||||
enum YoloTensorType { float32, uint8, int8, float16, unsupported }
|
||||
|
||||
class YoloRuntime {
|
||||
final Interpreter _interpreter;
|
||||
final List<int> inputShape;
|
||||
final List<int> outputShape;
|
||||
final YoloTensorType inputType;
|
||||
final YoloTensorType outputType;
|
||||
final int outputByteLength;
|
||||
|
||||
YoloRuntime._({
|
||||
required Interpreter interpreter,
|
||||
required this.inputShape,
|
||||
required this.outputShape,
|
||||
required this.inputType,
|
||||
required this.outputType,
|
||||
required this.outputByteLength,
|
||||
}) : _interpreter = interpreter;
|
||||
|
||||
static Future<YoloRuntime> load(String assetPath) async {
|
||||
final options = InterpreterOptions()..threads = 2;
|
||||
final interpreter =
|
||||
await Interpreter.fromAsset(assetPath, options: options);
|
||||
final input = interpreter.getInputTensor(0);
|
||||
final output = interpreter.getOutputTensor(0);
|
||||
return YoloRuntime._(
|
||||
interpreter: interpreter,
|
||||
inputShape: input.shape,
|
||||
outputShape: output.shape,
|
||||
inputType: _mapTensorType(input.type),
|
||||
outputType: _mapTensorType(output.type),
|
||||
outputByteLength: output.numBytes(),
|
||||
);
|
||||
}
|
||||
|
||||
void run(Uint8List input, Uint8List output) {
|
||||
_interpreter.run(input, output);
|
||||
}
|
||||
|
||||
void close() {
|
||||
_interpreter.close();
|
||||
}
|
||||
}
|
||||
|
||||
YoloTensorType _mapTensorType(TensorType type) {
|
||||
return switch (type) {
|
||||
TensorType.float32 => YoloTensorType.float32,
|
||||
TensorType.uint8 => YoloTensorType.uint8,
|
||||
TensorType.int8 => YoloTensorType.int8,
|
||||
TensorType.float16 => YoloTensorType.float16,
|
||||
_ => YoloTensorType.unsupported,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
enum YoloTensorType { float32, uint8, int8, float16, unsupported }
|
||||
|
||||
class YoloRuntime {
|
||||
const YoloRuntime();
|
||||
|
||||
static Future<YoloRuntime> load(String assetPath) async {
|
||||
throw UnsupportedError('YOLO TFLite runtime is only available on mobile.');
|
||||
}
|
||||
|
||||
List<int> get inputShape => const [];
|
||||
List<int> get outputShape => const [];
|
||||
YoloTensorType get inputType => YoloTensorType.unsupported;
|
||||
YoloTensorType get outputType => YoloTensorType.unsupported;
|
||||
int get outputByteLength => 0;
|
||||
|
||||
void run(Uint8List input, Uint8List output) {
|
||||
throw UnsupportedError('YOLO TFLite runtime is only available on mobile.');
|
||||
}
|
||||
|
||||
void close() {}
|
||||
}
|
||||
@@ -48,8 +48,8 @@ class _ActivityLogScreenState extends State<ActivityLogScreen> {
|
||||
final res = await _api
|
||||
.get('/user/activity-logs')
|
||||
.timeout(const Duration(seconds: 10));
|
||||
final list = (res.data['data'] as List?) ?? [];
|
||||
final items = list.map((e) => _LogItem.fromJson(e)).toList();
|
||||
final list = _extractList(res.data);
|
||||
final items = list.map(_LogItem.fromJson).toList();
|
||||
setState(() {
|
||||
_items = items;
|
||||
_applyFilter(_selectedFilter);
|
||||
@@ -66,6 +66,16 @@ class _ActivityLogScreenState extends State<ActivityLogScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _extractList(dynamic responseBody) {
|
||||
final data = responseBody is Map ? responseBody['data'] : null;
|
||||
final rawList = data is Map ? data['content'] : data;
|
||||
if (rawList is! List) return const [];
|
||||
return rawList
|
||||
.whereType<Map>()
|
||||
.map((item) => Map<String, dynamic>.from(item))
|
||||
.toList();
|
||||
}
|
||||
|
||||
void _applyFilter(String filter) {
|
||||
_selectedFilter = filter;
|
||||
if (filter == 'ALL') {
|
||||
|
||||
@@ -40,7 +40,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
final res = await _api
|
||||
.get('/user/notifications')
|
||||
.timeout(const Duration(seconds: 10));
|
||||
final list = (res.data['data'] as List?) ?? [];
|
||||
final list = _extractList(res.data);
|
||||
setState(() {
|
||||
_items = list.map((e) => _NotifItem.fromJson(e)).toList();
|
||||
});
|
||||
@@ -56,10 +56,20 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _extractList(dynamic responseBody) {
|
||||
final data = responseBody is Map ? responseBody['data'] : null;
|
||||
final rawList = data is Map ? data['content'] : data;
|
||||
if (rawList is! List) return const [];
|
||||
return rawList
|
||||
.whereType<Map>()
|
||||
.map((item) => Map<String, dynamic>.from(item))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> _markRead(int id) async {
|
||||
try {
|
||||
await _api
|
||||
.patch('/user/notifications/$id/read')
|
||||
.put('/user/notifications/$id/read')
|
||||
.timeout(const Duration(seconds: 6));
|
||||
setState(() {
|
||||
final idx = _items.indexWhere((n) => n.id == id);
|
||||
@@ -72,7 +82,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
setState(() => _markingAll = true);
|
||||
try {
|
||||
await _api
|
||||
.patch('/user/notifications/read-all')
|
||||
.put('/user/notifications/mark-all-read')
|
||||
.timeout(const Duration(seconds: 8));
|
||||
setState(() {
|
||||
_items = _items.map((n) => n.copyWith(isRead: true)).toList();
|
||||
|
||||
@@ -356,9 +356,16 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
String _status = 'Ready';
|
||||
CameraController? _camera;
|
||||
DetectionResult? _lastDetection;
|
||||
bool _processingFrame = false;
|
||||
DateTime _lastInferenceAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
DateTime _lastAlertAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final camera = _camera;
|
||||
if (camera != null && camera.value.isStreamingImages) {
|
||||
unawaited(camera.stopImageStream().catchError((Object _) {}));
|
||||
}
|
||||
_camera?.dispose();
|
||||
sl<LocationReporterService>().stop();
|
||||
super.dispose();
|
||||
@@ -370,8 +377,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
await _startCamera();
|
||||
await sl<LocationReporterService>().start(walkGuideActive: true);
|
||||
} else {
|
||||
await _camera?.dispose();
|
||||
_camera = null;
|
||||
await _stopCamera();
|
||||
await sl<LocationReporterService>().stop();
|
||||
}
|
||||
setState(() {
|
||||
@@ -388,35 +394,89 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
final cameras = await availableCameras();
|
||||
if (cameras.isEmpty) return;
|
||||
final controller = CameraController(
|
||||
cameras.first, ResolutionPreset.medium,
|
||||
enableAudio: false);
|
||||
cameras.first,
|
||||
ResolutionPreset.medium,
|
||||
enableAudio: false,
|
||||
imageFormatGroup: ImageFormatGroup.yuv420,
|
||||
);
|
||||
await controller.initialize();
|
||||
if (!mounted) {
|
||||
await controller.dispose();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await controller.startImageStream(_onCameraImage);
|
||||
} catch (_) {
|
||||
// Preview still works; manual Demo Detect remains available.
|
||||
}
|
||||
setState(() => _camera = controller);
|
||||
} catch (_) {
|
||||
setState(() => _status = 'Camera unavailable. Demo mode active.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopCamera() async {
|
||||
final camera = _camera;
|
||||
_camera = null;
|
||||
if (camera == null) return;
|
||||
try {
|
||||
if (camera.value.isStreamingImages) {
|
||||
await camera.stopImageStream();
|
||||
}
|
||||
} catch (_) {}
|
||||
await camera.dispose();
|
||||
}
|
||||
|
||||
void _onCameraImage(CameraImage image) {
|
||||
if (!_active || _processingFrame) return;
|
||||
final now = DateTime.now();
|
||||
if (now.difference(_lastInferenceAt) < const Duration(milliseconds: 900)) {
|
||||
return;
|
||||
}
|
||||
_lastInferenceAt = now;
|
||||
_processingFrame = true;
|
||||
unawaited(_runYolo(image).whenComplete(() => _processingFrame = false));
|
||||
}
|
||||
|
||||
Future<void> _runYolo(CameraImage image) async {
|
||||
final detection = await sl<YoloDetector>().detect(image);
|
||||
if (detection == null || !mounted) return;
|
||||
await _handleDetection(detection);
|
||||
}
|
||||
|
||||
Future<void> _simulateObstacle() async {
|
||||
final detection = await sl<YoloDetector>().detectFallback();
|
||||
if (detection == null) return;
|
||||
await _handleDetection(detection, forceAlert: true);
|
||||
}
|
||||
|
||||
Future<void> _handleDetection(
|
||||
DetectionResult detection, {
|
||||
bool forceAlert = false,
|
||||
}) async {
|
||||
_lastDetection = detection;
|
||||
await _api.post('/user/obstacle', data: {
|
||||
'label': detection.label,
|
||||
'confidence': detection.confidence,
|
||||
'direction': detection.directionName,
|
||||
'estimatedDist': detection.estimatedDistance,
|
||||
'lat': null,
|
||||
'lng': null,
|
||||
});
|
||||
await sl<HapticService>().obstacleClose();
|
||||
await sl<TtsService>().speakImmediate(detection.spokenId);
|
||||
setState(() => _status =
|
||||
'Obstacle: ${detection.label} ${detection.directionName} ${detection.estimatedDistance}');
|
||||
|
||||
final now = DateTime.now();
|
||||
if (!forceAlert &&
|
||||
now.difference(_lastAlertAt) < const Duration(seconds: 4)) {
|
||||
return;
|
||||
}
|
||||
_lastAlertAt = now;
|
||||
|
||||
try {
|
||||
await _api.post('/user/obstacle', data: {
|
||||
'label': detection.label,
|
||||
'confidence': detection.confidence,
|
||||
'direction': detection.directionName,
|
||||
'estimatedDist': detection.estimatedDistance,
|
||||
'lat': null,
|
||||
'lng': null,
|
||||
});
|
||||
} catch (_) {}
|
||||
await sl<HapticService>().obstacleClose();
|
||||
await sl<TtsService>().speakImmediate(detection.spokenId);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1148,12 +1208,8 @@ class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
|
||||
String label = 'person';
|
||||
String direction = 'CENTER';
|
||||
String distance = 'Demo';
|
||||
var modelLoaded = false;
|
||||
try {
|
||||
await rootBundle.load(_selectedModel).timeout(const Duration(seconds: 3));
|
||||
modelLoaded = true;
|
||||
} catch (_) {}
|
||||
final detection = await sl<YoloDetector>().detectFallback();
|
||||
final modelLoaded = sl<YoloDetector>().isReady;
|
||||
final detection = await sl<YoloDetector>().detectSynthetic();
|
||||
if (detection != null) {
|
||||
label = detection.label;
|
||||
direction = detection.directionName;
|
||||
|
||||
@@ -33,9 +33,16 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
String _status = 'Ready';
|
||||
CameraController? _camera;
|
||||
DetectionResult? _lastDetection;
|
||||
bool _processingFrame = false;
|
||||
DateTime _lastInferenceAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
DateTime _lastAlertAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final camera = _camera;
|
||||
if (camera != null && camera.value.isStreamingImages) {
|
||||
unawaited(camera.stopImageStream().catchError((Object _) {}));
|
||||
}
|
||||
_camera?.dispose();
|
||||
sl<LocationReporterService>().stop();
|
||||
super.dispose();
|
||||
@@ -47,8 +54,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
await _startCamera();
|
||||
await sl<LocationReporterService>().start(walkGuideActive: true);
|
||||
} else {
|
||||
await _camera?.dispose();
|
||||
_camera = null;
|
||||
await _stopCamera();
|
||||
await sl<LocationReporterService>().stop();
|
||||
}
|
||||
setState(() {
|
||||
@@ -67,35 +73,89 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
final cameras = await availableCameras();
|
||||
if (cameras.isEmpty) return;
|
||||
final controller = CameraController(
|
||||
cameras.first, ResolutionPreset.medium,
|
||||
enableAudio: false);
|
||||
cameras.first,
|
||||
ResolutionPreset.medium,
|
||||
enableAudio: false,
|
||||
imageFormatGroup: ImageFormatGroup.yuv420,
|
||||
);
|
||||
await controller.initialize();
|
||||
if (!mounted) {
|
||||
await controller.dispose();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await controller.startImageStream(_onCameraImage);
|
||||
} catch (_) {
|
||||
// Preview still works; manual Demo Detect remains available.
|
||||
}
|
||||
setState(() => _camera = controller);
|
||||
} catch (_) {
|
||||
setState(() => _status = 'Camera unavailable. Demo mode active.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopCamera() async {
|
||||
final camera = _camera;
|
||||
_camera = null;
|
||||
if (camera == null) return;
|
||||
try {
|
||||
if (camera.value.isStreamingImages) {
|
||||
await camera.stopImageStream();
|
||||
}
|
||||
} catch (_) {}
|
||||
await camera.dispose();
|
||||
}
|
||||
|
||||
void _onCameraImage(CameraImage image) {
|
||||
if (!_active || _processingFrame) return;
|
||||
final now = DateTime.now();
|
||||
if (now.difference(_lastInferenceAt) < const Duration(milliseconds: 900)) {
|
||||
return;
|
||||
}
|
||||
_lastInferenceAt = now;
|
||||
_processingFrame = true;
|
||||
unawaited(_runYolo(image).whenComplete(() => _processingFrame = false));
|
||||
}
|
||||
|
||||
Future<void> _runYolo(CameraImage image) async {
|
||||
final detection = await sl<YoloDetector>().detect(image);
|
||||
if (detection == null || !mounted) return;
|
||||
await _handleDetection(detection);
|
||||
}
|
||||
|
||||
Future<void> _simulateObstacle() async {
|
||||
final detection = await sl<YoloDetector>().detectFallback();
|
||||
if (detection == null) return;
|
||||
await _handleDetection(detection, forceAlert: true);
|
||||
}
|
||||
|
||||
Future<void> _handleDetection(
|
||||
DetectionResult detection, {
|
||||
bool forceAlert = false,
|
||||
}) async {
|
||||
_lastDetection = detection;
|
||||
await sl<ApiClient>().dio.post('/user/obstacle', data: {
|
||||
'label': detection.label,
|
||||
'confidence': detection.confidence,
|
||||
'direction': detection.directionName,
|
||||
'estimatedDist': detection.estimatedDistance,
|
||||
'lat': null,
|
||||
'lng': null,
|
||||
});
|
||||
await sl<HapticService>().obstacleClose();
|
||||
await sl<TtsService>().speakImmediate(detection.spokenId);
|
||||
setState(() => _status =
|
||||
'Obstacle: ${detection.label} ${detection.directionName} ${detection.estimatedDistance}');
|
||||
|
||||
final now = DateTime.now();
|
||||
if (!forceAlert &&
|
||||
now.difference(_lastAlertAt) < const Duration(seconds: 4)) {
|
||||
return;
|
||||
}
|
||||
_lastAlertAt = now;
|
||||
|
||||
try {
|
||||
await sl<ApiClient>().dio.post('/user/obstacle', data: {
|
||||
'label': detection.label,
|
||||
'confidence': detection.confidence,
|
||||
'direction': detection.directionName,
|
||||
'estimatedDist': detection.estimatedDistance,
|
||||
'lat': null,
|
||||
'lng': null,
|
||||
});
|
||||
} catch (_) {}
|
||||
await sl<HapticService>().obstacleClose();
|
||||
await sl<TtsService>().speakImmediate(detection.spokenId);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -235,12 +295,8 @@ class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
|
||||
String label = 'person';
|
||||
String direction = 'CENTER';
|
||||
String distance = 'Demo';
|
||||
var modelLoaded = false;
|
||||
try {
|
||||
await rootBundle.load(_selectedModel).timeout(const Duration(seconds: 3));
|
||||
modelLoaded = true;
|
||||
} catch (_) {}
|
||||
final detection = await sl<YoloDetector>().detectFallback();
|
||||
final modelLoaded = sl<YoloDetector>().isReady;
|
||||
final detection = await sl<YoloDetector>().detectSynthetic();
|
||||
if (detection != null) {
|
||||
label = detection.label;
|
||||
direction = detection.directionName;
|
||||
@@ -507,9 +563,7 @@ class _EmptyPanel extends StatelessWidget {
|
||||
final String title;
|
||||
final String message;
|
||||
const _EmptyPanel(
|
||||
{required this.icon,
|
||||
required this.title,
|
||||
required this.message});
|
||||
{required this.icon, required this.title, required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -1616,10 +1616,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: tflite_flutter
|
||||
sha256: ffb8651fdb116ab0131d6dc47ff73883e0f634ad1ab12bb2852eef1bbeab4a6a
|
||||
sha256: "0bba9040d8decda0960d7abf8eabf32243bf092bc7d0084e8e19681866b0bdbe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.4"
|
||||
version: "0.12.1"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -34,7 +34,7 @@ dependencies:
|
||||
|
||||
# Camera & AI
|
||||
camera: ^0.11.0+2
|
||||
tflite_flutter: ^0.10.4
|
||||
tflite_flutter: 0.12.1
|
||||
image: ^4.2.0
|
||||
|
||||
# Audio & TTS
|
||||
|
||||
Reference in New Issue
Block a user