Environment setup
Use Xcode 27 (the SDK version this course uses) on macOS. Create an iOS or macOS SwiftUI app target, set the target's deployment settings, and drag the exported .aimodel into the target's model/resources build phase. In Xcode, add the Core AI framework under Frameworks, Libraries, and Embedded Content. Install the Metal Toolchain from Xcode Settings > Components.
xcode-select --install
xcodebuild -version
# In Xcode: File > New > Project > App, Interface: SwiftUI
# Add CoreAI to the target and add the .aimodel to Copy Bundle Resources.
# Xcode Settings > Components > install Metal Toolchain.Build once with the bundled model before you add image or chat UI. Core AI and NDArray names can change between SDK versions. Check the exact names in the Xcode docs you have installed. Do not copy a name you have not checked.
This is the path the app should follow. The model is prepared once in task. When someone picks a photo, only decode the image, prepare it, and run the model. Keep the labels in exactly the Fashion-MNIST order from training.
Labels and view model
import SwiftUI
import PhotosUI
import UIKit
import CoreAI
let fashionLabels = [
"T-shirt/top", "Trouser", "Pullover", "Dress", "Coat",
"Sandal", "Shirt", "Sneaker", "Bag", "Ankle boot"
]
@MainActor
final class ClothingClassifier: ObservableObject {
@Published var result = "Choose a clothing photo"
@Published var confidence = 0.0
@Published var ready = false
private var function: InferenceFunction?
func prepare() async {
do {
let url = Bundle.main.url(forResource: "clothing", withExtension: "aimodel")!
let model = try await AIModel(contentsOf: url)
function = try model.loadFunction(named: "main")
ready = true
} catch {
result = "Model setup failed: \\(error.localizedDescription)"
}
}
func classify(_ image: CGImage) throws {
guard let function else { return }
let pixels = try grayscale28x28(image) // Float32 values in [0, 1]
let input = makeImageNDArray(
values: pixels,
shape: [1, 1, 28, 28],
dataType: .float32
)
let outputs = try function.run(
inputs: ["image": input], states: [:], outputViews: nil
)
let logits = readFloat32Values(from: outputs["logits"])
let probabilities = softmax(logits)
guard let best = probabilities.indices.max(by: {
probabilities[$0] < probabilities[$1]
}) else { return }
result = fashionLabels[best]
confidence = probabilities[best]
}
}
makeImageNDArray and readFloat32Values are small adapters around the NDArray constructors and output accessors in the SDK. Keep these rules visible: float32, NCHW, [1, 1, 28, 28], input key image, output key logits.
Resize, grayscale, and scale
func grayscale28x28(_ source: CGImage) throws -> [Float32] {
// Draw source into a 28x28, one-channel, 8-bit grayscale buffer.
// Use CGColorSpaceCreateDeviceGray and premultiplied-none alpha.
var bytes = [UInt8](repeating: 0, count: 28 * 28)
guard let context = CGContext(
data: &bytes, width: 28, height: 28,
bitsPerComponent: 8, bytesPerRow: 28,
space: CGColorSpaceCreateDeviceGray(),
bitmapInfo: CGImageAlphaInfo.none.rawValue
) else { throw ImageError.cannotCreateContext }
context.interpolationQuality = .high
context.draw(source, in: CGRect(x: 0, y: 0, width: 28, height: 28))
return bytes.map { Float32($0) / 255.0 }
}
func softmax(_ logits: [Float32]) -> [Float32] {
let peak = logits.max() ?? 0
let exps = logits.map { exp($0 - peak) }
let total = exps.reduce(0, +)
return exps.map { $0 / total }
}
For a normal photo, the clothing may not fill the square or match the dark Fashion-MNIST background. A real app should crop to the item, adjust contrast, and test whether inversion improves accuracy. Do that after the basic path works.
Photo picker and bundled sample
struct ContentView: View {
@StateObject private var classifier = ClothingClassifier()
@State private var selection: PhotosPickerItem?
@State private var preview: UIImage?
var body: some View {
VStack(spacing: 16) {
Group {
if let preview { Image(uiImage: preview).resizable().scaledToFit() }
else if let bundled = UIImage(named: "fashion-sample") {
Image(uiImage: bundled).resizable().scaledToFit()
} else { ContentUnavailableView("Pick a photo", systemImage: "tshirt") }
}
.frame(maxHeight: 280)
Text(classifier.result).font(.title2.bold())
if classifier.ready { Text("Confidence \\(classifier.confidence, format: .percent.precision(.fractionLength(1)))") }
PhotosPicker("Pick clothing photo", selection: $selection, matching: .images)
.disabled(!classifier.ready)
}
.padding()
.task { await classifier.prepare() }
.onChange(of: selection) { _, item in
Task {
guard let data = try? await item?.loadTransferable(type: Data.self),
let image = UIImage(data: data), let cgImage = image.cgImage else { return }
preview = image
try? classifier.classify(cgImage)
}
}
}
}
Add a small fashion-sample image to the target so the first run can work offline. The picker is optional. The same image prep and classifier path handles both the bundled sample and a picked image.
NDArray names are new. Check the exact model loading, array construction, output access, and PhotosPicker overloads against the Xcode 27 SDK docs. The app structure stays the same: load the model once, prepare the image the same way as training, run inference, apply softmax, then show the label.Key concepts
ClothingClassifierprepares in.taskand classifies when a photo or bundled sample arrives.- Labels stay in the fixed Fashion-MNIST order. Softmax on logits is for display confidence only.
- Draw the image to 28x28 grayscale, scale bytes to 0-1, and send key
image. - Reuse the loaded function
mainfor every image after prepare once.
Takeaways
- Model load and specialisation never run inside the picker
onChangehandler. - The top label comes from argmax or the softmax index into the ten-label array.
- Real photos may need crop or contrast after the basic path works.