Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 0.2.1

- **Bug Fix**: Fix OBB (Oriented Bounding Box) coordinate retrieval in detection results
- **Flutter**: Fixed missing `polygon` field in OBB detection results, enabling `YOLOResult.obbPoints` to be properly extracted
- To print OBB values in the example app, log `result.obbPoints` in the camera screen's `onDetectionResults` callback (e.g. `for (final r in results) { if (r.obbPoints != null) print(r.obbPoints); }`)

## 0.2.0

- Documentation update for model downloads:
Expand Down
1 change: 1 addition & 0 deletions android/src/main/kotlin/com/ultralytics/yolo/YOLOPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ class YOLOPlugin : FlutterPlugin, ActivityAware, MethodChannel.MethodCallHandler
mapOf(
"points" to poly.map { mapOf("x" to it.x, "y" to it.y) },
"class" to obb.cls,
"classIndex" to obb.index,
"confidence" to obb.confidence
)
}
Expand Down
10 changes: 5 additions & 5 deletions example/lib/presentation/screens/camera_inference_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class CameraInferenceScreen extends StatefulWidget {
class _CameraInferenceScreenState extends State<CameraInferenceScreen> {
late final CameraInferenceController _controller;
int _rebuildKey = 0;
bool _wasRouteCurrent = true;

@override
void initState() {
Expand All @@ -41,11 +42,10 @@ class _CameraInferenceScreenState extends State<CameraInferenceScreen> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Check if route is current (we've navigated back to this screen)
final route = ModalRoute.of(context);
if (route?.isCurrent == true) {
// Force rebuild when navigating back to ensure camera restarts
// The rebuild will create a new YOLOView which will automatically start the camera
final isCurrent = ModalRoute.of(context)?.isCurrent == true;
final shouldRebuild = isCurrent && !_wasRouteCurrent;
_wasRouteCurrent = isCurrent;
if (shouldRebuild) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Ultralytics πŸš€ AGPL-3.0 License - https://ultralytics.com/license

import 'package:flutter/material.dart';
import 'package:ultralytics_yolo/models/yolo_task.dart';
import 'package:ultralytics_yolo/yolo_streaming_config.dart';
import 'package:ultralytics_yolo/yolo_view.dart';
import '../controllers/camera_inference_controller.dart';
Expand All @@ -27,7 +28,9 @@ class CameraInferenceContent extends StatelessWidget {
controller: controller.yoloController,
modelPath: controller.modelPath!,
task: controller.selectedModel.task,
streamingConfig: const YOLOStreamingConfig.minimal(),
streamingConfig: controller.selectedModel.task == YOLOTask.obb
? const YOLOStreamingConfig.custom(includeOBB: true)
: const YOLOStreamingConfig.minimal(),
onResult: controller.onDetectionResults,
onPerformanceMetrics: (metrics) =>
controller.onPerformanceMetrics(metrics.fps),
Expand Down
36 changes: 17 additions & 19 deletions ios/Classes/BasePredictor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -362,27 +362,25 @@ public class BasePredictor: Predictor, @unchecked Sendable {
/// - Parameter model: The CoreML model to analyze.
/// - Returns: A tuple containing the width and height in pixels required by the model.
func getModelInputSize(for model: MLModel) -> (width: Int, height: Int) {
guard let inputDescription = model.modelDescription.inputDescriptionsByName.first?.value else {
print("can not find input description")
return (0, 0)
}

if let multiArrayConstraint = inputDescription.multiArrayConstraint {
let shape = multiArrayConstraint.shape
if shape.count >= 2 {
let height = shape[0].intValue
let width = shape[1].intValue
return (width: width, height: height)
for (_, inputDescription) in model.modelDescription.inputDescriptionsByName {
if let multiArrayConstraint = inputDescription.multiArrayConstraint {
let shape = multiArrayConstraint.shape
if shape.count >= 4 {
let height = shape[2].intValue
let width = shape[3].intValue
if width > 0, height > 0 { return (width: width, height: height) }
} else if shape.count >= 2 {
let height = shape[0].intValue
let width = shape[1].intValue
if width > 0, height > 0 { return (width: width, height: height) }
}
}
if let imageConstraint = inputDescription.imageConstraint {
let width = Int(imageConstraint.pixelsWide)
let height = Int(imageConstraint.pixelsHigh)
if width > 0, height > 0 { return (width: width, height: height) }
}
}

if let imageConstraint = inputDescription.imageConstraint {
let width = Int(imageConstraint.pixelsWide)
let height = Int(imageConstraint.pixelsHigh)
return (width: width, height: height)
}

print("an not find input size")
return (0, 0)
}

Expand Down
1 change: 1 addition & 0 deletions ios/Classes/YOLOInstanceManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ class YOLOInstanceManager {
obbArray.append([
"points": points,
"class": obbResult.cls,
"classIndex": obbResult.index,
"confidence": obbResult.confidence,
])
}
Expand Down
47 changes: 44 additions & 3 deletions ios/Classes/YOLOView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -204,14 +204,16 @@ public class YOLOView: UIView, VideoCaptureDelegate {
) {
self.videoCapture = VideoCapture()
super.init(frame: frame)
setModel(modelPathOrName: modelPathOrName, task: task)
setUpOrientationChangeNotification()
self.setUpBoundingBoxViews()
self.setupUI()
self.videoCapture.delegate = self
// Hide UI controls by default
self.showUIControls = false
start(position: cameraPosition)
setModel(modelPathOrName: modelPathOrName, task: task) { [weak self] result in
if case .success = result {
self?.start(position: cameraPosition)
}
}
setupOverlayLayer()
}

Expand Down Expand Up @@ -1765,6 +1767,45 @@ extension YOLOView: AVCapturePhotoCaptureDelegate {

detections.append(detection)
}

if config.includeOBB && !result.obb.isEmpty {
for obbResult in result.obb {
let obbBox = obbResult.box
let polygon = obbBox.toPolygon()
let points = polygon.map { point in
["x": Double(point.x), "y": Double(point.y)] as [String: Any]
}
var minX = polygon.map(\.x).min() ?? 0
var maxX = polygon.map(\.x).max() ?? 0
var minY = polygon.map(\.y).min() ?? 0
var maxY = polygon.map(\.y).max() ?? 0
let w = result.orig_shape.width
let h = result.orig_shape.height
if w > 0, h > 0 {
let boundingBox: [String: Any] = [
"left": Double(minX),
"top": Double(minY),
"right": Double(maxX),
"bottom": Double(maxY),
]
let normalizedBox: [String: Any] = [
"left": Double(minX / CGFloat(w)),
"top": Double(minY / CGFloat(h)),
"right": Double(maxX / CGFloat(w)),
"bottom": Double(maxY / CGFloat(h)),
]
var detection: [String: Any] = [:]
detection["classIndex"] = obbResult.index
detection["className"] = obbResult.cls
detection["confidence"] = Double(obbResult.confidence)
detection["boundingBox"] = boundingBox
detection["normalizedBox"] = normalizedBox
detection["polygon"] = points
detections.append(detection)
}
}
}

map["detections"] = detections
}

Expand Down
43 changes: 28 additions & 15 deletions lib/core/yolo_inference.dart
Original file line number Diff line number Diff line change
Expand Up @@ -210,30 +210,42 @@ class YOLOInference {

for (final obb in obbList) {
if (obb is Map) {
final obbMap = MapConverter.convertToTypedMap(obb);
final points = obb['points'] as List<dynamic>? ?? [];

final polygonPoints = points.whereType<Map>().map((point) {
final pointMap = MapConverter.convertToTypedMap(point);
return {
'x': MapConverter.safeGetDouble(pointMap, 'x'),
'y': MapConverter.safeGetDouble(pointMap, 'y'),
};
}).toList();

double minX = double.infinity, minY = double.infinity;
double maxX = double.negativeInfinity, maxY = double.negativeInfinity;
for (final p in polygonPoints) {
final x = (p['x'] as num?)?.toDouble() ?? 0.0;
final y = (p['y'] as num?)?.toDouble() ?? 0.0;
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
}

for (final point in points) {
if (point is Map) {
final pointMap = MapConverter.convertToTypedMap(point);
final x = MapConverter.safeGetDouble(pointMap, 'x');
final y = MapConverter.safeGetDouble(pointMap, 'y');
minX = minX > x ? x : minX;
minY = minY > y ? y : minY;
maxX = maxX < x ? x : maxX;
maxY = maxY < y ? y : maxY;
}
final hasValidExtrema =
minX.isFinite && minY.isFinite && maxX.isFinite && maxY.isFinite;

if (!hasValidExtrema) {
minX = 0.0;
minY = 0.0;
maxX = 0.0;
maxY = 0.0;
}

final detection = <String, dynamic>{
'classIndex': 0,
'classIndex': MapConverter.safeGetInt(obbMap, 'classIndex'),
'className': obb['class'] ?? '',
'confidence': MapConverter.safeGetDouble(
MapConverter.convertToTypedMap(obb),
'confidence',
),
'confidence': MapConverter.safeGetDouble(obbMap, 'confidence'),
'boundingBox': {
'left': minX,
'top': minY,
Expand All @@ -246,6 +258,7 @@ class YOLOInference {
'right': maxX,
'bottom': maxY,
},
'polygon': polygonPoints,
};

detections.add(detection);
Expand Down
21 changes: 19 additions & 2 deletions lib/models/yolo_result.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
// Ultralytics πŸš€ AGPL-3.0 License - https://ultralytics.com/license

// lib/yolo_result.dart

import 'dart:typed_data';
import 'dart:ui';
import '../utils/map_converter.dart';
Expand Down Expand Up @@ -74,6 +72,9 @@ class YOLOResult {
/// and ranges from 0.0 to 1.0.
final List<double>? keypointConfidences;

/// The oriented bounding box points for rotated object detection.
final List<Map<String, num>>? obbPoints;

YOLOResult({
required this.classIndex,
required this.className,
Expand All @@ -83,6 +84,7 @@ class YOLOResult {
this.mask,
this.keypoints,
this.keypointConfidences,
this.obbPoints,
});

/// Creates a [YOLOResult] from a map representation.
Expand Down Expand Up @@ -130,6 +132,18 @@ class YOLOResult {
keypointConfidences = keypointResult.confidences;
}

final polygonRaw = map['polygon'] ?? map['obbPoints'];
final polygon = polygonRaw is List ? polygonRaw : null;
final List<Map<String, num>>? obbPoints = polygon?.whereType<Map>().map((
item,
) {
final m = MapConverter.convertToTypedMap(item);
return <String, num>{
'x': MapConverter.safeGetDouble(m, 'x'),
'y': MapConverter.safeGetDouble(m, 'y'),
};
}).toList();

return YOLOResult(
classIndex: classIndex,
className: className,
Expand All @@ -139,6 +153,7 @@ class YOLOResult {
mask: mask,
keypoints: keypoints,
keypointConfidences: keypointConfidences,
obbPoints: obbPoints,
);
}

Expand All @@ -159,6 +174,8 @@ class YOLOResult {
'right': normalizedBox.right,
'bottom': normalizedBox.bottom,
},

if (obbPoints != null) 'polygon': obbPoints,
};

if (mask != null) {
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

name: ultralytics_yolo
description: Flutter plugin for Ultralytics YOLO computer vision models.
version: 0.2.0
version: 0.2.1
homepage: https://github.com/ultralytics/yolo-flutter-app
repository: https://github.com/ultralytics/yolo-flutter-app
issue_tracker: https://github.com/ultralytics/yolo-flutter-app/issues
Expand Down
69 changes: 69 additions & 0 deletions test/yolo_result_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,75 @@ void main() {
expect(result.keypointConfidences, isNull);
});

test('fromMap safely handles polygon with mixed types and nulls', () {
final map = {
'classIndex': 1,
'className': 'airplane',
'confidence': 0.92,
'boundingBox': {
'left': 100.0,
'top': 150.0,
'right': 300.0,
'bottom': 250.0,
},
'normalizedBox': {
'left': 0.1,
'top': 0.15,
'right': 0.3,
'bottom': 0.25,
},

'polygon': [
{'x': 100.0, 'y': 150.0},
{'x': 300, 'y': 150},
{'x': '300.0', 'y': '250.0'},
{'x': null, 'y': 250.0},
{'x': 100.0, 'y': 'invalid'},
],
};

final result = YOLOResult.fromMap(map);

expect(result.obbPoints, isNotNull);
expect(result.obbPoints!.length, greaterThanOrEqualTo(2));
expect(result.obbPoints![0]['x'], 100.0);
expect(result.obbPoints![0]['y'], 150.0);
expect(result.obbPoints![1]['x'], 300);
expect(result.obbPoints![1]['y'], 150);
});

test('toMap and fromMap round-trip correctly with polygon key', () {
final original = YOLOResult(
classIndex: 2,
className: 'airplane',
confidence: 0.92,
boundingBox: const Rect.fromLTRB(100, 150, 300, 250),
normalizedBox: const Rect.fromLTRB(0.1, 0.15, 0.3, 0.25),
obbPoints: [
{'x': 100.0, 'y': 150.0},
{'x': 300.0, 'y': 150.0},
{'x': 300.0, 'y': 250.0},
{'x': 100.0, 'y': 250.0},
],
);

final map = original.toMap();
final roundTripped = YOLOResult.fromMap(map);

expect(roundTripped.classIndex, original.classIndex);
expect(roundTripped.className, original.className);
expect(roundTripped.confidence, original.confidence);
expect(roundTripped.obbPoints, isNotNull);
expect(roundTripped.obbPoints!.length, original.obbPoints!.length);
expect(map.containsKey('polygon'), isTrue);
expect(map['polygon'], isA<List>());

for (var i = 0; i < original.obbPoints!.length; i++) {
expect(roundTripped.obbPoints![i]['x'], original.obbPoints![i]['x']);
expect(roundTripped.obbPoints![i]['y'], original.obbPoints![i]['y']);
}
});

test('constructor creates instance with all parameters', () {
final keypoints = [Point(0.5, 0.3), Point(0.6, 0.4)];
final keypointConfidences = [0.8, 0.9];
Expand Down
Loading