Skip to content

Commit dd124a8

Browse files
Dynamic and Batch Support for Object Detector (#29)
1 parent e53b8b3 commit dd124a8

5 files changed

Lines changed: 423 additions & 76 deletions

File tree

models/yolo/README.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,22 +44,37 @@ uv run export.py --help
4444
### In your iOS and macOS applications
4545

4646
```swift
47-
import ObjectDetector
48-
49-
// Detection parameters
50-
let params = DetectionParameters()
47+
import CoreAIObjectDetector
5148

5249
// Load directly from an exported .aimodel directory.
5350
let detector = try await ObjectDetector(resourcesAt: "coreai-models/exports/yolos-base_float32_static.aimodel")
5451

55-
// Run inference
56-
let detections = try await detector.detect(image: cgImage, parameters: params)
52+
// Single image, default parameters.
53+
let detections = try await detector.detect(image: cgImage)
54+
55+
// Batched detection. For dynamic-shape exports, optionally override the spatial dims
56+
// on DetectionParameters; for static exports the values are ignored.
57+
var params = DetectionParameters()
58+
params.inputHeight = 800
59+
params.inputWidth = 1024
60+
let batchDetections = try await detector.detect(images: [imageA, imageB], parameters: params)
61+
62+
// Optional: warm up the kernel for the exact (B, H, W) you'll run with.
63+
try await detector.warmup(imageCount: 2, parameters: params)
5764
```
5865

5966
### On your Mac using built-in Command Line Tool
6067

6168
```bash
69+
# Single image, static-shape model.
6270
swift run -c release object-detector --model path/to/exported_model.aimodel --image path/to/image.jpg
71+
72+
# Batched detection on a dynamic-shape export, with optional, explicit input dims and warmup.
73+
swift run -c release object-detector \
74+
--model path/to/dynamic.aimodel \
75+
--image a.jpg --image b.jpg \
76+
--input-height 800 --input-width 1024 \
77+
--warmup
6378
```
6479

6580
[^1]: [Paper](https://arxiv.org/abs/2106.00666) · [HuggingFace](https://huggingface.co/hustvl/yolos-tiny)

swift/Sources/CoreAIObjectDetector/DetectionOutputs.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,18 +73,32 @@ public struct DetectionParameters: Sendable {
7373
/// When empty, labels default to "class_N".
7474
public var classLabels: [Int: String]
7575

76+
/// Model input height. Only consulted when the model declares a dynamic
77+
/// spatial dimension; ignored for static-shape models. Defaults to 800
78+
/// (matches the YOLOS export's reference input and the training-time
79+
/// canvas geometry).
80+
public var inputHeight: Int
81+
82+
/// Model input width. Only consulted when the model declares a dynamic
83+
/// spatial dimension; ignored for static-shape models. Defaults to 800.
84+
public var inputWidth: Int
85+
7686
public init(
7787
threshold: Float = 0.3,
7888
maxDetections: Int = 100,
7989
normalizationMeans: (CGFloat, CGFloat, CGFloat) = (0.485, 0.456, 0.406),
8090
normalizationStds: (CGFloat, CGFloat, CGFloat) = (0.229, 0.224, 0.225),
81-
classLabels: [Int: String] = ObjectDetectionLabels.coco
91+
classLabels: [Int: String] = ObjectDetectionLabels.coco,
92+
inputHeight: Int = 800,
93+
inputWidth: Int = 800
8294
) {
8395
self.threshold = threshold
8496
self.maxDetections = maxDetections
8597
self.normalizationMeans = normalizationMeans
8698
self.normalizationStds = normalizationStds
8799
self.classLabels = classLabels
100+
self.inputHeight = inputHeight
101+
self.inputWidth = inputWidth
88102
}
89103

90104
public static let `default` = DetectionParameters()

swift/Sources/CoreAIObjectDetector/ObjectDetector.swift

Lines changed: 183 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)