Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 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 +59,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