@@ -87,68 +87,200 @@ public struct ObjectDetector {
8787 " No array descriptor for image input ' \( imageInputName) ' "
8888 )
8989 }
90- let imageArray = NDArray ( descriptor: imageDescriptor)
91- _ = try await function. run ( inputs: [ imageInputName: imageArray] )
90+ let defaults = DetectionParameters ( )
91+ let warmupShape = zip ( imageDescriptor. shape, [ 1 , 3 , defaults. inputHeight, defaults. inputWidth] )
92+ . map { actual, fallback in actual >= 0 ? actual : fallback }
93+ let resolved = imageDescriptor. resolvingDynamicDimensions ( warmupShape)
94+ _ = try await function. run ( inputs: [ imageInputName: NDArray ( descriptor: resolved) ] )
9295 }
9396
9497 /// Detect objects in `image` using `.default` parameters.
9598 public func detect( image: CGImage ) async throws -> [ DetectedObject ] {
9699 try await detect ( image: image, parameters: . default)
97100 }
98101
99- /// Detect objects in `image`.
102+ /// Detect objects in `image` — convenience wrapper over the batched API .
100103 public func detect( image: CGImage , parameters: DetectionParameters ) async throws -> [ DetectedObject ] {
101- // Build image NDArray
104+ let results = try await detect ( images: [ image] , parameters: parameters)
105+ return results. first ?? [ ]
106+ }
107+
108+ /// Detect objects in each of `images` using `.default` parameters.
109+ public func detect( images: [ CGImage ] ) async throws -> [ [ DetectedObject ] ] {
110+ try await detect ( images: images, parameters: . default)
111+ }
112+
113+ /// Detect objects across `images` in a single batched forward pass.
114+ ///
115+ /// Pipeline:
116+ /// 1. Resolve a batch plan `(B, H, W)` from the model descriptor and
117+ /// parameters. Batch is always `images.count`. Dynamic spatial dims
118+ /// are filled from `parameters.inputHeight` / `inputWidth` (which
119+ /// have struct-level defaults).
120+ /// 2. Preprocess each image sequentially into a `[3, H, W]` Float buffer.
121+ /// 3. Concatenate the per-image buffers into the `[B, 3, H, W]` input
122+ /// NDArray and run a single forward pass.
123+ /// 4. Slice each batch slot from the outputs and decode independently,
124+ /// returning `images.count` detection lists in input order.
125+ public func detect( images: [ CGImage ] , parameters: DetectionParameters ) async throws
126+ -> [ [ DetectedObject ] ]
127+ {
128+ guard !images. isEmpty else {
129+ throw DetectionRuntimeError . invalidConfiguration ( " detect requires at least one image " )
130+ }
102131 guard case . ndArray( let imageDescriptor) = functionDescriptor. inputDescriptor ( of: imageInputName) else {
103132 throw DetectionRuntimeError . invalidConfiguration (
104133 " No array descriptor for image input ' \( imageInputName) ' "
105134 )
106135 }
107-
108136 let expectedShape = imageDescriptor. shape
109137 guard expectedShape. count == 4 else {
110138 throw DetectionRuntimeError . invalidConfiguration (
111139 " Expected 4-dimensional input shape, got \( expectedShape. count) "
112140 )
113141 }
114- let height = expectedShape [ 2 ]
115- let width = expectedShape [ 3 ]
116- let floatPixels = try ImagePreprocessor (
117- targetSize: CGSize ( width: width, height: height) ,
142+
143+ let plan = try Self . planBatch (
144+ expectedShape: expectedShape,
145+ imageSizes: images. map { CGSize ( width: $0. width, height: $0. height) } ,
146+ parameters: parameters
147+ )
148+
149+ // 1. Preprocess each input image (sequential).
150+ let perImagePixels = try preprocessImages ( images, plan: plan, parameters: parameters)
151+
152+ // 2. Build batched NDArray and run inference once.
153+ let resolvedDescriptor = imageDescriptor. resolvingDynamicDimensions (
154+ [ plan. batch, 3 , plan. height, plan. width] )
155+ let imageArray = try buildInputNDArray ( descriptor: resolvedDescriptor, perImagePixels: perImagePixels)
156+
157+ var outputs = try await function. run ( inputs: [ imageInputName: imageArray] )
158+ guard let logitsArray = outputs. remove ( logitsOutputName) ? . ndArray,
159+ let boxesArray = outputs. remove ( boxesOutputName) ? . ndArray
160+ else {
161+ throw DetectionRuntimeError . invalidConfiguration (
162+ " Missing one or more outputs after run. "
163+ )
164+ }
165+
166+ // 3. Decode each input image's batch slot.
167+ return Self . decodePerImage (
168+ logitsArray: logitsArray,
169+ boxesArray: boxesArray,
170+ images: images,
171+ parameters: parameters
172+ )
173+ }
174+
175+ // MARK: - Preprocessing
176+
177+ /// Sequentially preprocess each image to a `[3 * H * W]` Float buffer at
178+ /// the plan's target spatial dimensions.
179+ private func preprocessImages(
180+ _ images: [ CGImage ] , plan: BatchPlan , parameters: DetectionParameters
181+ ) throws -> [ [ Float ] ] {
182+ let preprocessor = ImagePreprocessor (
183+ targetSize: CGSize ( width: plan. width, height: plan. height) ,
118184 mean: parameters. normalizationMeans,
119185 std: parameters. normalizationStds,
120186 rescaleFactor: 1.0
121- ) . preprocessCHW ( cgImage : image )
122-
123- var imageArray = NDArray ( descriptor : imageDescriptor )
187+ )
188+ return try images . map { try preprocessor . preprocessCHW ( cgImage : $0 ) }
189+ }
124190
125- if imageDescriptor. scalarType == . float16 {
191+ /// Build the input NDArray for a `[B, 3, H, W]` resolved descriptor by
192+ /// concatenating per-image CHW buffers in batch order. Each per-image
193+ /// entry is `3*H*W` floats; the buffers are written contiguously to match
194+ /// row-major batch-leading layout.
195+ private func buildInputNDArray(
196+ descriptor: NDArrayDescriptor , perImagePixels: [ [ Float ] ]
197+ ) throws -> NDArray {
198+ var imageArray = NDArray ( descriptor: descriptor)
199+ let flat = Array ( perImagePixels. joined ( ) )
200+ if descriptor. scalarType == . float16 {
126201 #if !((os(macOS) || targetEnvironment(macCatalyst)) && arch(x86_64))
127- fillNDArray ( & imageArray, as: Float16 . self, with: floatPixels . map ( Float16 . init) )
202+ fillNDArray ( & imageArray, as: Float16 . self, with: flat . map ( Float16 . init) )
128203 #else
129204 fatalError ( " Float16 is not supported on this platform " )
130205 #endif
131206 } else {
132- fillNDArray ( & imageArray, as: Float . self, with: floatPixels )
207+ fillNDArray ( & imageArray, as: Float . self, with: flat )
133208 }
209+ return imageArray
210+ }
134211
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 {
212+ // MARK: - Output decoding
213+
214+ private static func decodePerImage(
215+ logitsArray: NDArray ,
216+ boxesArray: NDArray ,
217+ images: [ CGImage ] ,
218+ parameters: DetectionParameters
219+ ) -> [ [ DetectedObject ] ] {
220+ let logitsShape = logitsArray. shape // [B, Q, C]
221+ let boxesShape = boxesArray. shape // [B, Q, 4]
222+ let logitsAll = flattenAsFloat ( logitsArray)
223+ let boxesAll = flattenAsFloat ( boxesArray)
224+ let perBatchLog = logitsShape. dropFirst ( ) . reduce ( 1 , * )
225+ let perBatchBox = boxesShape. dropFirst ( ) . reduce ( 1 , * )
226+ let singleBatchLogitsShape = [ 1 ] + logitsShape. dropFirst ( )
227+
228+ return images. enumerated ( ) . map { i, image in
229+ let raw = DetectionOutput (
230+ logits: Array ( logitsAll [ i * perBatchLog..< ( i + 1 ) * perBatchLog] ) ,
231+ logitsShape: singleBatchLogitsShape,
232+ predictedBoxes: Array ( boxesAll [ i * perBatchBox..< ( i + 1 ) * perBatchBox] )
233+ )
234+ return DetectionPostprocessor . decode (
235+ output: raw,
236+ inputSize: CGSize ( width: image. width, height: image. height) ,
237+ parameters: parameters
238+ )
239+ }
240+ }
241+
242+ // MARK: - Batch planning
243+
244+ struct BatchPlan : Equatable {
245+ let batch : Int
246+ let height : Int
247+ let width : Int
248+ }
249+
250+ /// Resolve the concrete `(B, H, W)` to bind the model with, given the
251+ /// model's expected shape (which may contain `-1` for dynamic dims), the
252+ /// list of input image sizes, and the user's parameter overrides.
253+ ///
254+ /// Resolution rules:
255+ /// - **Batch**: always `images.count`. A static-batch model must match.
256+ /// - **Spatial dims**: a dynamic `-1` dim is filled from
257+ /// `parameters.inputHeight` / `inputWidth`. A static dim is taken
258+ /// from the model descriptor (the parameters' values are ignored for
259+ /// that axis).
260+ static func planBatch(
261+ expectedShape: [ Int ] ,
262+ imageSizes: [ CGSize ] ,
263+ parameters: DetectionParameters
264+ ) throws -> BatchPlan {
265+ guard !imageSizes. isEmpty else {
266+ throw DetectionRuntimeError . invalidConfiguration ( " planBatch requires at least one image " )
267+ }
268+
269+ // Resolve batch from image count; verify it matches a static batch dim.
270+ let targetBatch = imageSizes. count
271+ let batchExpected = expectedShape [ 0 ]
272+ if batchExpected >= 0 && batchExpected != targetBatch {
140273 throw DetectionRuntimeError . invalidConfiguration (
141- " Missing one or more outputs after run. "
274+ " Model expects fixed batch= \( batchExpected ) but caller supplied \( targetBatch ) image(s) "
142275 )
143276 }
144277
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)
278+ let heightExpected = expectedShape [ 2 ]
279+ let widthExpected = expectedShape [ 3 ]
280+ let height = heightExpected < 0 ? parameters. inputHeight : heightExpected
281+ let width = widthExpected < 0 ? parameters. inputWidth : widthExpected
282+
283+ return BatchPlan ( batch: targetBatch, height: height, width: width)
152284 }
153285
154286 // MARK: - Name Discovery
0 commit comments