Skip to content
Merged
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
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
## 0.1.45

- **Critical Bug Fix**: Fix fatal crash when camera permission is denied or not granted on iOS
- **iOS Fix**:
- Added camera authorization status check (`AVCaptureDevice.authorizationStatus`) before attempting to access camera
- Replaced `try!` with proper `do-catch` error handling for `AVCaptureDeviceInput` initialization

## 0.1.44

- **Critical Bug Fix**: Fix SIGSEGV crash when YOLOView is disposed while TensorFlow Lite inference is running
- **Root Cause**: Race condition where `onFrame` callback continued executing after `stop()` cleared resources and closed the TensorFlow Lite interpreter
- **Fix**: Added `@Volatile` `isStopped` flag that is checked at multiple points in `onFrame` to prevent accessing closed resources

## 0.1.43

- **Enhancement**: Unify classification output format across all platforms to use official Results.summary() format
Expand Down Expand Up @@ -53,7 +66,6 @@
## 0.1.38

- **Bug Fix**: iOS performance metrics not updating in `YOLOView`

- Moved EventChannel subscription from `initState` to `_onPlatformViewCreated` to ensure native channel readiness on iOS
- Aligned streaming config key with iOS by renaming `throttleInterval` to `throttleIntervalMs` when sending params
- iOS now sources performance metrics from the latest inference result: `processingTimeMs = result.speed * 1000`, `fps = result.fps`
Expand Down
33 changes: 25 additions & 8 deletions android/src/main/kotlin/com/ultralytics/yolo/YOLOPlatformView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package com.ultralytics.yolo
import android.content.Context
import android.util.Log
import android.view.View
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
Expand Down Expand Up @@ -49,6 +50,7 @@ class YOLOPlatformView(
private var retryRunnable: Runnable? = null

init {
Log.d(TAG, "YOLOPlatformView[$viewId] INIT BLOCK STARTING")
val dartViewIdParam = creationParams?.get("viewId")
viewUniqueId = dartViewIdParam as? String ?: viewId.toString().also {
Log.w(TAG, "YOLOPlatformView[$viewId init]: Using platform int viewId '$it' as fallback")
Expand Down Expand Up @@ -85,14 +87,19 @@ class YOLOPlatformView(
// Configure YOLOView streaming functionality
setupYOLOViewStreaming(creationParams)

// Initialize camera
Log.d(TAG, "Attempting early camera initialization")
yoloView.initCamera()

// Notify lifecycle if available
// Notify lifecycle FIRST before initializing camera
// This ensures lifecycle owner is set before camera tries to start
if (context is LifecycleOwner) {
Log.d(TAG, "Initial context is a LifecycleOwner, notifying YOLOView")
yoloView.onLifecycleOwnerAvailable(context)
// initCamera will be called by onLifecycleOwnerAvailable if permissions are granted
// But we also call it here as a fallback
Log.d(TAG, "Attempting camera initialization after lifecycle owner setup")
yoloView.initCamera()
} else {
Log.w(TAG, "Context is not a LifecycleOwner, camera may not start")
// Still try to initialize camera - it will request permissions if needed
yoloView.initCamera()
}

try {
Expand All @@ -105,12 +112,17 @@ class YOLOPlatformView(
// Set up model loading callback
yoloView.setOnModelLoadCallback { success ->
if (success) {
Log.d(TAG, "Model loaded successfully")
initialized = true
// Start streaming if not already started
startStreaming()
val context = yoloView.context
val hasPermissions = android.Manifest.permission.CAMERA.let { permission ->
android.content.pm.PackageManager.PERMISSION_GRANTED ==
ContextCompat.checkSelfPermission(context, permission)
}
if (hasPermissions) {
yoloView.startCamera()
}
} else {
Log.w(TAG, "Failed to load model")
initialized = true
}
}
Expand Down Expand Up @@ -418,6 +430,11 @@ class YOLOPlatformView(
Log.d(TAG, "Camera and inference stopped")
result.success(null)
}
"restartCamera" -> {
Log.d(TAG, "Restarting camera (requested from Flutter)")
yoloView.startCamera()
result.success(null)
}
"captureFrame" -> {
val imageData = yoloView.captureFrame()
if (imageData != null) {
Expand Down
76 changes: 65 additions & 11 deletions android/src/main/kotlin/com/ultralytics/yolo/YOLOView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,11 @@ class YOLOView @JvmOverloads constructor(

// New fields for proper teardown:
private var cameraExecutor: ExecutorService? = null
private var imageAnalysisUseCase: ImageAnalysis? = null
private var imageAnalysisUseCase: ImageAnalysis? = null

// Flag to track if the view is stopped/disposed to prevent race conditions
@Volatile
private var isStopped = false

// Zoom related
private var currentZoomRatio = 1.0f
Expand Down Expand Up @@ -419,7 +423,10 @@ class YOLOView @JvmOverloads constructor(
this.modelName = modelPath.substringAfterLast("/")
modelLoadCallback?.invoke(true)
callback?.invoke(true)
Log.d(TAG, "Model loaded successfully: $modelPath")
// Ensure camera starts after model loads if it's not already running
if (allPermissionsGranted() && lifecycleOwner != null && (camera == null || isStopped)) {
startCamera()
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to load model: $modelPath. Camera will run without inference.", e)
Expand Down Expand Up @@ -466,21 +473,20 @@ class YOLOView @JvmOverloads constructor(
*/
fun onLifecycleOwnerAvailable(owner: LifecycleOwner) {
this.lifecycleOwner = owner
// Register as a lifecycle observer to handle lifecycle events
owner.lifecycle.addObserver(this)

// If camera was requested but couldn't start due to missing lifecycle owner, try again
if (allPermissionsGranted()) {
if (allPermissionsGranted() && (camera == null || isStopped)) {
startCamera()
}
Log.d(TAG, "LifecycleOwner set: ${owner.javaClass.simpleName}")
}

// region camera init

fun initCamera() {
if (allPermissionsGranted()) {
startCamera()
if (lifecycleOwner != null && (camera == null || isStopped)) {
startCamera()
}
} else {
val activity = context as? Activity ?: return
ActivityCompat.requestPermissions(
Expand Down Expand Up @@ -510,7 +516,7 @@ class YOLOView @JvmOverloads constructor(
}

fun startCamera() {
Log.d(TAG, "Starting camera...")
isStopped = false

try {
cameraProviderFuture = ProcessCameraProvider.getInstance(context)
Expand Down Expand Up @@ -605,9 +611,27 @@ class YOLOView @JvmOverloads constructor(

// Lifecycle methods from DefaultLifecycleObserver
override fun onStart(owner: LifecycleOwner) {
Log.d(TAG, "Lifecycle onStart")
Log.d(TAG, "Lifecycle onStart - restarting camera if stopped")
if (allPermissionsGranted()) {
startCamera()
// Always restart camera on start if it's stopped or null
// This ensures camera resumes when navigating back
if (isStopped || camera == null) {
Log.d(TAG, "Camera is stopped or null, restarting on onStart")
startCamera()
} else {
Log.d(TAG, "Camera is already running, no restart needed")
}
}
}

override fun onResume(owner: LifecycleOwner) {
Log.d(TAG, "Lifecycle onResume - ensuring camera is running")
if (allPermissionsGranted()) {
// Double-check camera is running on resume
if (isStopped || camera == null) {
Log.d(TAG, "Camera not running on resume, restarting...")
startCamera()
}
}
}

Expand All @@ -619,6 +643,13 @@ class YOLOView @JvmOverloads constructor(
// region onFrame (per frame inference)

private fun onFrame(imageProxy: ImageProxy) {
// Early return if view is stopped to prevent accessing closed resources
if (isStopped) {
Log.d(TAG, "onFrame: View is stopped, skipping frame processing")
imageProxy.close()
return
}

val w = imageProxy.width
val h = imageProxy.height
val orientation = context.resources.configuration.orientation
Expand All @@ -630,7 +661,21 @@ class YOLOView @JvmOverloads constructor(
return
}

// Check again after bitmap conversion (in case stop() was called during conversion)
if (isStopped) {
Log.d(TAG, "onFrame: View stopped during bitmap conversion, skipping inference")
imageProxy.close()
return
}

predictor?.let { p ->
// Double-check stopped flag before inference (predictor might be closed)
if (isStopped) {
Log.d(TAG, "onFrame: View stopped before inference, skipping")
imageProxy.close()
return
}

// Check if we should run inference on this frame
if (!shouldRunInference()) {
Log.d(TAG, "Skipping inference due to frequency control")
Expand Down Expand Up @@ -1773,6 +1818,9 @@ class YOLOView @JvmOverloads constructor(
*/
fun stop() {
Log.d(TAG, "YOLOView.stop() called - tearing down camera")

// Set stopped flag first to prevent new frames from being processed
isStopped = true

try {
imageAnalysisUseCase?.clearAnalyzer()
Expand Down Expand Up @@ -1811,7 +1859,13 @@ class YOLOView @JvmOverloads constructor(
cameraExecutor = null

camera = null
(predictor as? BasePredictor)?.close()

// Close predictor safely - ensure no inference is running
try {
(predictor as? BasePredictor)?.close()
} catch (e: Exception) {
Log.e(TAG, "Error closing predictor", e)
}
predictor = null
inferenceCallback = null
streamCallback = null
Expand Down
26 changes: 25 additions & 1 deletion example/lib/presentation/screens/camera_inference_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class CameraInferenceScreen extends StatefulWidget {

class _CameraInferenceScreenState extends State<CameraInferenceScreen> {
late final CameraInferenceController _controller;
int _rebuildKey = 0;

@override
void initState() {
Expand All @@ -37,6 +38,24 @@ 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
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_rebuildKey++;
});
}
});
}
}

@override
void dispose() {
_controller.dispose();
Expand All @@ -49,12 +68,17 @@ class _CameraInferenceScreenState extends State<CameraInferenceScreen> {
MediaQuery.of(context).orientation == Orientation.landscape;

return Scaffold(
appBar: AppBar(title: const Text('YOLO Camera Inference')),
body: ListenableBuilder(
listenable: _controller,
builder: (context, child) {
return Stack(
children: [
CameraInferenceContent(controller: _controller),
CameraInferenceContent(
key: ValueKey('camera_content_$_rebuildKey'),
controller: _controller,
rebuildKey: _rebuildKey,
),
CameraInferenceOverlay(
controller: _controller,
isLandscape: isLandscape,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,21 @@ import 'model_loading_overlay.dart';

/// Main content widget that handles the camera view and loading states
class CameraInferenceContent extends StatelessWidget {
const CameraInferenceContent({super.key, required this.controller});
const CameraInferenceContent({
super.key,
required this.controller,
this.rebuildKey = 0,
});

final CameraInferenceController controller;
final int rebuildKey;

@override
Widget build(BuildContext context) {
if (controller.modelPath != null && !controller.isModelLoading) {
return YOLOView(
key: ValueKey(
'yolo_view_${controller.modelPath}_${controller.selectedModel.task.name}',
'yolo_view_${controller.modelPath}_${controller.selectedModel.task.name}_$rebuildKey',
),
controller: controller.yoloController,
modelPath: controller.modelPath!,
Expand Down
Loading
Loading