From 33bb4ab916ef22727d7afdcc7c53e8a3a8ef36a6 Mon Sep 17 00:00:00 2001 From: Ahmet Toker Date: Tue, 12 May 2026 23:04:43 +0300 Subject: [PATCH] fix(ios): replace per-frame CIContext+createCGImage with CMSampleBuffer path Each call to pixelBufferToVisionImage created a fresh CIContext and ran createCGImage(_:from:) on it. Even after the local CIContext is released by ARC, the underlying CI::SurfaceCacheEntry retains the IOSurface in the system's IOAccelerator warm cache. Under sustained camera streaming this leaks ~3.5 MiB per call (one 720p BGRA frame buffer), reaching 300+ MiB of VM:IOSurface memory in 60 seconds and continuing until the OS terminates the app. A shared CIContext + autoreleasepool does not stop the growth (Apple Developer Forums thread 17142). The only way to bypass the cache is to avoid createCGImage entirely. VisionImage(buffer: CMSampleBuffer) accepts the CVPixelBuffer wrapped in a CMSampleBuffer directly. CoreImage is no longer involved, so no SurfaceCacheEntry is ever created. This is also the path Google's official MLKit iOS sample uses (googlesamples/mlkit/ios/quickstarts/vision/VisionExample/CameraViewController.swift). Verified on a private fork: VM:IOSurface growth in a 60s no-face camera stream drops from 341 MiB to near-baseline. Detection accuracy and latency are unchanged. See #863 for full Instruments traces and measurements. --- .../MLKVisionImage+FlutterPlugin.swift | 49 ++++++++++++++----- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/packages/google_mlkit_commons/ios/Classes/MLKVisionImage+FlutterPlugin.swift b/packages/google_mlkit_commons/ios/Classes/MLKVisionImage+FlutterPlugin.swift index d2265806..6552bab2 100644 --- a/packages/google_mlkit_commons/ios/Classes/MLKVisionImage+FlutterPlugin.swift +++ b/packages/google_mlkit_commons/ios/Classes/MLKVisionImage+FlutterPlugin.swift @@ -2,6 +2,7 @@ import Flutter import MLKitVision import UIKit import CoreGraphics +import CoreMedia import CoreVideo // MARK: - VisionImage from Flutter imageData @@ -111,20 +112,46 @@ extension VisionImage { return pxBuffer } + // Wraps a CVPixelBuffer in a CMSampleBuffer and feeds it to MLKit via + // VisionImage(buffer:). The previous implementation created a fresh CIContext + // per frame and called createCGImage(_:from:); each call inserts a + // CI::SurfaceCacheEntry that the system never releases under sustained + // camera streaming, leaking ~3.5 MiB of IOSurface memory per call (300+ MiB + // per minute on 720p BGRA). VisionImage(buffer:) bypasses CoreImage entirely + // and is the same path Google's official MLKit iOS sample uses + // (googlesamples/mlkit CameraViewController.swift). private static func pixelBufferToVisionImage(_ pixelBufferRef: CVPixelBuffer) -> VisionImage? { - let ciImage = CIImage(cvPixelBuffer: pixelBufferRef) - let context = CIContext(options: nil) - let width = CVPixelBufferGetWidth(pixelBufferRef) - let height = CVPixelBufferGetHeight(pixelBufferRef) - guard let cgImage = context.createCGImage( - ciImage, - from: CGRect(x: 0, y: 0, width: width, height: height) - ) else { + var formatDesc: CMVideoFormatDescription? + let formatStatus = CMVideoFormatDescriptionCreateForImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: pixelBufferRef, + formatDescriptionOut: &formatDesc + ) + guard formatStatus == noErr, let formatDescription = formatDesc else { + return nil + } + + var timing = CMSampleTimingInfo( + duration: .invalid, + presentationTimeStamp: .zero, + decodeTimeStamp: .invalid + ) + var sampleBuffer: CMSampleBuffer? + let sampleStatus = CMSampleBufferCreateForImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: pixelBufferRef, + dataReady: true, + makeDataReadyCallback: nil, + refcon: nil, + formatDescription: formatDescription, + sampleTiming: &timing, + sampleBufferOut: &sampleBuffer + ) + guard sampleStatus == noErr, let sampleBuffer = sampleBuffer else { return nil } - // Swift ARC manages CGImage; UIImage(cgImage:) retains it. - let uiImage = UIImage(cgImage: cgImage) - return VisionImage(image: uiImage) + + return VisionImage(buffer: sampleBuffer) } private static func bitmapToVisionImage(_ imageDict: [String: Any]) -> VisionImage? {