@@ -80,75 +80,228 @@ public struct ObjectDetector {
8080
8181 // MARK: - Inference
8282
83- /// Warm up the backend (e.g. trigger Metal kernel compilation) with a dummy pass.
84- public func warmup( ) async throws {
83+ /// Warm up the backend (e.g. trigger Metal kernel compilation) with a dummy
84+ /// pass at the same `(B, H, W)` that subsequent `detect()` calls will use.
85+ /// For static-shape models the arguments are ignored — `planBatch` falls
86+ /// back to the descriptor's fixed dims.
87+ public func warmup( imageCount: Int = 1 , parameters: DetectionParameters = . default) async throws {
8588 guard case . ndArray( let imageDescriptor) = functionDescriptor. inputDescriptor ( of: imageInputName) else {
8689 throw DetectionRuntimeError . invalidConfiguration (
8790 " No array descriptor for image input ' \( imageInputName) ' "
8891 )
8992 }
90- let imageArray = NDArray ( descriptor: imageDescriptor)
91- _ = try await function. run ( inputs: [ imageInputName: imageArray] )
93+ let expectedShape = imageDescriptor. shape
94+ guard expectedShape. count == 4 else {
95+ throw DetectionRuntimeError . invalidConfiguration (
96+ " Expected 4-dimensional input shape, got \( expectedShape. count) "
97+ )
98+ }
99+ let plan = try Self . planBatch (
100+ expectedShape: expectedShape,
101+ imageCount: imageCount,
102+ parameters: parameters
103+ )
104+ let resolved = imageDescriptor. resolvingDynamicDimensions (
105+ [ plan. batch, 3 , plan. height, plan. width] )
106+ _ = try await function. run ( inputs: [ imageInputName: NDArray ( descriptor: resolved) ] )
92107 }
93108
94109 /// Detect objects in `image` using `.default` parameters.
95110 public func detect( image: CGImage ) async throws -> [ DetectedObject ] {
96111 try await detect ( image: image, parameters: . default)
97112 }
98113
99- /// Detect objects in `image`.
114+ /// Detect objects in `image` — convenience wrapper over the batched API .
100115 public func detect( image: CGImage , parameters: DetectionParameters ) async throws -> [ DetectedObject ] {
101- // Build image NDArray
116+ let results = try await detect ( images: [ image] , parameters: parameters)
117+ return results. first ?? [ ]
118+ }
119+
120+ /// Detect objects in each of `images` using `.default` parameters.
121+ public func detect( images: [ CGImage ] ) async throws -> [ [ DetectedObject ] ] {
122+ try await detect ( images: images, parameters: . default)
123+ }
124+
125+ /// Detect objects across `images` in a single batched forward pass.
126+ ///
127+ /// Pipeline:
128+ /// 1. Resolve a batch plan `(B, H, W)` from the model descriptor and
129+ /// parameters. Batch is always `images.count`. Dynamic spatial dims
130+ /// are filled from `parameters.inputHeight` / `inputWidth` (which
131+ /// have struct-level defaults).
132+ /// 2. Allocate the `[B, 3, H, W]` input NDArray and preprocess each
133+ /// image directly into its batch slot, then run a single forward pass.
134+ /// 3. Slice each batch slot from the outputs and decode independently,
135+ /// returning `images.count` detection lists in input order.
136+ public func detect( images: [ CGImage ] , parameters: DetectionParameters ) async throws
137+ -> [ [ DetectedObject ] ]
138+ {
139+ guard !images. isEmpty else {
140+ throw DetectionRuntimeError . invalidConfiguration ( " detect requires at least one image " )
141+ }
102142 guard case . ndArray( let imageDescriptor) = functionDescriptor. inputDescriptor ( of: imageInputName) else {
103143 throw DetectionRuntimeError . invalidConfiguration (
104144 " No array descriptor for image input ' \( imageInputName) ' "
105145 )
106146 }
107-
108147 let expectedShape = imageDescriptor. shape
109148 guard expectedShape. count == 4 else {
110149 throw DetectionRuntimeError . invalidConfiguration (
111150 " Expected 4-dimensional input shape, got \( expectedShape. count) "
112151 )
113152 }
114- let height = expectedShape [ 2 ]
115- let width = expectedShape [ 3 ]
116- let floatPixels = try ImagePreprocessor (
117- targetSize: CGSize ( width: width, height: height) ,
153+
154+ let plan = try Self . planBatch (
155+ expectedShape: expectedShape,
156+ imageCount: images. count,
157+ parameters: parameters
158+ )
159+
160+ // 1. Allocate the batched input NDArray and write each image's
161+ // preprocessed CHW pixels directly into its batch slot.
162+ let resolvedDescriptor = imageDescriptor. resolvingDynamicDimensions (
163+ [ plan. batch, 3 , plan. height, plan. width] )
164+ let imageArray = try buildInputNDArray (
165+ images: images, plan: plan, descriptor: resolvedDescriptor, parameters: parameters)
166+
167+ var outputs = try await function. run ( inputs: [ imageInputName: imageArray] )
168+ guard let logitsArray = outputs. remove ( logitsOutputName) ? . ndArray,
169+ let boxesArray = outputs. remove ( boxesOutputName) ? . ndArray
170+ else {
171+ throw DetectionRuntimeError . invalidConfiguration (
172+ " Missing one or more outputs after run. "
173+ )
174+ }
175+
176+ // 3. Decode each input image's batch slot.
177+ return Self . decodePerImage (
178+ logitsArray: logitsArray,
179+ boxesArray: boxesArray,
180+ images: images,
181+ parameters: parameters
182+ )
183+ }
184+
185+ // MARK: - Preprocessing
186+
187+ /// Preprocess each image and write its `[3, H, W]` Float pixels directly
188+ /// into the corresponding batch slot of a freshly allocated `[B, 3, H, W]`
189+ /// NDArray. Avoids materializing a per-image `[Float]` array-of-arrays
190+ /// or a flattened `B*3*H*W` intermediate.
191+ private func buildInputNDArray(
192+ images: [ CGImage ] ,
193+ plan: BatchPlan ,
194+ descriptor: NDArrayDescriptor ,
195+ parameters: DetectionParameters
196+ ) throws -> NDArray {
197+ let preprocessor = ImagePreprocessor (
198+ targetSize: CGSize ( width: plan. width, height: plan. height) ,
118199 mean: parameters. normalizationMeans,
119200 std: parameters. normalizationStds,
120201 rescaleFactor: 1.0
121- ) . preprocessCHW ( cgImage : image )
122-
123- var imageArray = NDArray ( descriptor: imageDescriptor )
202+ )
203+ let slotCount = 3 * plan . height * plan . width
204+ var imageArray = NDArray ( descriptor: descriptor )
124205
125- if imageDescriptor . scalarType == . float16 {
206+ if descriptor . scalarType == . float16 {
126207 #if !((os(macOS) || targetEnvironment(macCatalyst)) && arch(x86_64))
127- fillNDArray ( & imageArray, as: Float16 . self, with: floatPixels. map ( Float16 . init) )
208+ var view = imageArray. mutableView ( as: Float16 . self)
209+ for (b, image) in images. enumerated ( ) {
210+ let chw = try preprocessor. preprocessCHW ( cgImage: image)
211+ view. withUnsafeMutablePointer { ptr, _, _ in
212+ let slot = ptr. advanced ( by: b * slotCount)
213+ for i in 0 ..< slotCount { slot [ i] = Float16 ( chw [ i] ) }
214+ }
215+ }
128216 #else
129217 fatalError ( " Float16 is not supported on this platform " )
130218 #endif
131219 } else {
132- fillNDArray ( & imageArray, as: Float . self, with: floatPixels)
220+ var view = imageArray. mutableView ( as: Float . self)
221+ for (b, image) in images. enumerated ( ) {
222+ let chw = try preprocessor. preprocessCHW ( cgImage: image)
223+ view. withUnsafeMutablePointer { ptr, _, _ in
224+ let slot = ptr. advanced ( by: b * slotCount)
225+ chw. withUnsafeBufferPointer { src in
226+ slot. update ( from: src. baseAddress!, count: slotCount)
227+ }
228+ }
229+ }
133230 }
231+ return imageArray
232+ }
134233
135- // Run inference and extract outputs
136- var outputs = try await function. run ( inputs: [ imageInputName: imageArray] )
137- guard let logitsArray = outputs. remove ( logitsOutputName) ? . ndArray,
138- let boxesArray = outputs. remove ( boxesOutputName) ? . ndArray
139- else {
234+ // MARK: - Output decoding
235+
236+ private static func decodePerImage(
237+ logitsArray: NDArray ,
238+ boxesArray: NDArray ,
239+ images: [ CGImage ] ,
240+ parameters: DetectionParameters
241+ ) -> [ [ DetectedObject ] ] {
242+ let logitsShape = logitsArray. shape // [B, Q, C]
243+ let boxesShape = boxesArray. shape // [B, Q, 4]
244+ let logitsAll = flattenAsFloat ( logitsArray)
245+ let boxesAll = flattenAsFloat ( boxesArray)
246+ let perBatchLog = logitsShape. dropFirst ( ) . reduce ( 1 , * )
247+ let perBatchBox = boxesShape. dropFirst ( ) . reduce ( 1 , * )
248+ let singleBatchLogitsShape = [ 1 ] + logitsShape. dropFirst ( )
249+
250+ return images. enumerated ( ) . map { i, image in
251+ let raw = DetectionOutput (
252+ logits: Array ( logitsAll [ i * perBatchLog..< ( i + 1 ) * perBatchLog] ) ,
253+ logitsShape: singleBatchLogitsShape,
254+ predictedBoxes: Array ( boxesAll [ i * perBatchBox..< ( i + 1 ) * perBatchBox] )
255+ )
256+ return DetectionPostprocessor . decode (
257+ output: raw,
258+ inputSize: CGSize ( width: image. width, height: image. height) ,
259+ parameters: parameters
260+ )
261+ }
262+ }
263+
264+ // MARK: - Batch planning
265+
266+ struct BatchPlan : Equatable {
267+ let batch : Int
268+ let height : Int
269+ let width : Int
270+ }
271+
272+ /// Resolve the concrete `(B, H, W)` to bind the model with, given the
273+ /// model's expected shape (which may contain `-1` for dynamic dims), the
274+ /// number of input images, and the user's parameter overrides.
275+ ///
276+ /// Resolution rules:
277+ /// - **Batch**: always `imageCount`. A static-batch model must match.
278+ /// - **Spatial dims**: a dynamic `-1` dim is filled from
279+ /// `parameters.inputHeight` / `inputWidth`. A static dim is taken
280+ /// from the model descriptor (the parameters' values are ignored for
281+ /// that axis).
282+ static func planBatch(
283+ expectedShape: [ Int ] ,
284+ imageCount: Int ,
285+ parameters: DetectionParameters
286+ ) throws -> BatchPlan {
287+ guard imageCount >= 1 else {
288+ throw DetectionRuntimeError . invalidConfiguration ( " planBatch requires imageCount >= 1 " )
289+ }
290+
291+ // Verify image count matches a static batch dim.
292+ let batchExpected = expectedShape [ 0 ]
293+ if batchExpected >= 0 && batchExpected != imageCount {
140294 throw DetectionRuntimeError . invalidConfiguration (
141- " Missing one or more outputs after run. "
295+ " Model expects fixed batch= \( batchExpected ) but caller supplied \( imageCount ) image(s) "
142296 )
143297 }
144298
145- let rawOutput = DetectionOutput (
146- logits: flattenAsFloat ( logitsArray) ,
147- logitsShape: logitsArray. shape,
148- predictedBoxes: flattenAsFloat ( boxesArray)
149- )
150- let inputSize = CGSize ( width: image. width, height: image. height)
151- return DetectionPostprocessor . decode ( output: rawOutput, inputSize: inputSize, parameters: parameters)
299+ let heightExpected = expectedShape [ 2 ]
300+ let widthExpected = expectedShape [ 3 ]
301+ let height = heightExpected < 0 ? parameters. inputHeight : heightExpected
302+ let width = widthExpected < 0 ? parameters. inputWidth : widthExpected
303+
304+ return BatchPlan ( batch: imageCount, height: height, width: width)
152305 }
153306
154307 // MARK: - Name Discovery
0 commit comments