Skip to content

Commit 845ac27

Browse files
committed
dynamic batch size, image H and W for object detector. tested with YoloS
1 parent 0c1055f commit 845ac27

4 files changed

Lines changed: 388 additions & 67 deletions

File tree

swift/Sources/CoreAIObjectDetector/DetectionOutputs.swift

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

82+
/// Model input height. Only consulted when the model declares a dynamic
83+
/// spatial dimension; ignored for static-shape models. Defaults to 800
84+
/// (matches the YOLOS export's reference input and the training-time
85+
/// canvas geometry).
86+
public var inputHeight: Int
87+
88+
/// Model input width. Only consulted when the model declares a dynamic
89+
/// spatial dimension; ignored for static-shape models. Defaults to 800.
90+
public var inputWidth: Int
91+
8292
public init(
8393
threshold: Float = 0.3,
8494
maxDetections: Int = 100,
8595
normalizationMeans: (CGFloat, CGFloat, CGFloat) = (0.485, 0.456, 0.406),
8696
normalizationStds: (CGFloat, CGFloat, CGFloat) = (0.229, 0.224, 0.225),
87-
classLabels: [Int: String] = ObjectDetectionLabels.coco
97+
classLabels: [Int: String] = ObjectDetectionLabels.coco,
98+
inputHeight: Int = 800,
99+
inputWidth: Int = 800
88100
) {
89101
self.threshold = threshold
90102
self.maxDetections = maxDetections
91103
self.normalizationMeans = normalizationMeans
92104
self.normalizationStds = normalizationStds
93105
self.classLabels = classLabels
106+
self.inputHeight = inputHeight
107+
self.inputWidth = inputWidth
94108
}
95109

96110
public static let `default` = DetectionParameters()

swift/Sources/CoreAIObjectDetector/ObjectDetector.swift

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

Comments
 (0)