Kyle Pearson commited on
Commit ·
2806f00
1
Parent(s): ac6a5ca
Pre-resize images to exact model dimensions, implement feathered blending to eliminate seam artifacts, cache model constraints to unify coordinate space
Browse files- DepthPredictor.swift +149 -16
- test/depth.png +3 -0
DepthPredictor.swift
CHANGED
|
@@ -138,9 +138,17 @@ final class DepthPredictor {
|
|
| 138 |
private var visionModel: VNCoreMLModel?
|
| 139 |
private var _outputHeight = 512
|
| 140 |
private var _outputWidth = 1024
|
|
|
|
|
|
|
| 141 |
|
| 142 |
var outputHeight: Int { _outputHeight }
|
| 143 |
var outputWidth: Int { _outputWidth }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
var isLoaded: Bool { visionModel != nil }
|
| 145 |
|
| 146 |
/// Load model dynamically from a .mlpackage or .mlmodelc URL.
|
|
@@ -174,6 +182,15 @@ final class DepthPredictor {
|
|
| 174 |
}
|
| 175 |
|
| 176 |
/// Run a single pass of depth inference on a CGImage.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
private func runSingleInference(on cgImage: CGImage, completion: @escaping (DepthResult?) -> Void) {
|
| 178 |
guard let visionModel else {
|
| 179 |
print("[DepthPredictor] Model not loaded")
|
|
@@ -181,6 +198,18 @@ final class DepthPredictor {
|
|
| 181 |
return
|
| 182 |
}
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
let request = VNCoreMLRequest(model: visionModel) { [weak self] request, error in
|
| 185 |
if let error {
|
| 186 |
print("[DepthPredictor] Inference error: \(error)")
|
|
@@ -205,7 +234,7 @@ final class DepthPredictor {
|
|
| 205 |
|
| 206 |
request.imageCropAndScaleOption = .scaleFit
|
| 207 |
|
| 208 |
-
let handler = VNImageRequestHandler(cgImage:
|
| 209 |
do {
|
| 210 |
try handler.perform([request])
|
| 211 |
} catch {
|
|
@@ -237,11 +266,25 @@ final class DepthPredictor {
|
|
| 237 |
patchHalfWidth: Int = 25,
|
| 238 |
completion: @escaping (DepthResult?) -> Void
|
| 239 |
) {
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
let half = imageWidth / 2
|
| 242 |
|
| 243 |
// Shift the source image left by half — the seam moves to the center
|
| 244 |
-
guard let shiftedImage = DepthPredictor.shiftImageHorizontally(
|
| 245 |
print("[DepthPredictor] Failed to shift image for seam fix")
|
| 246 |
completion(nil)
|
| 247 |
return
|
|
@@ -255,8 +298,8 @@ final class DepthPredictor {
|
|
| 255 |
)
|
| 256 |
}
|
| 257 |
|
| 258 |
-
// 1. Infer depth on the original image
|
| 259 |
-
runSingleInference(on:
|
| 260 |
guard let self, let originalDepth else {
|
| 261 |
completion(nil)
|
| 262 |
return
|
|
@@ -314,21 +357,36 @@ final class DepthPredictor {
|
|
| 314 |
}
|
| 315 |
}
|
| 316 |
|
| 317 |
-
/// Stitch the seam region using a single output buffer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
///
|
| 319 |
-
///
|
| 320 |
-
/// patch zone (the strip around the original seam at column `depthHalf`).
|
| 321 |
-
/// If so, we read from the shifted depth at the corresponding shifted
|
| 322 |
-
/// column; otherwise we read from the original depth at `col` directly.
|
| 323 |
///
|
| 324 |
-
///
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
private func stitchSeamFromShiftedDepth(
|
| 326 |
original: MLMultiArray,
|
| 327 |
shifted: MLMultiArray,
|
| 328 |
width: Int,
|
| 329 |
height: Int,
|
| 330 |
depthHalf: Int,
|
| 331 |
-
patchHalfWidth: Int
|
|
|
|
| 332 |
) -> MLMultiArray? {
|
| 333 |
let origView = DepthArrayView(original)
|
| 334 |
let shiftView = DepthArrayView(shifted)
|
|
@@ -339,6 +397,13 @@ final class DepthPredictor {
|
|
| 339 |
let patchLeft = centerX - dx
|
| 340 |
let patchRight = centerX + dx // exclusive
|
| 341 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
// Create output MLMultiArray
|
| 343 |
let output: MLMultiArray
|
| 344 |
do {
|
|
@@ -351,6 +416,9 @@ final class DepthPredictor {
|
|
| 351 |
let outStride = output.strides[2].intValue
|
| 352 |
let outPtr = output.dataPointer.bindMemory(to: Float32.self, capacity: width * height)
|
| 353 |
|
|
|
|
|
|
|
|
|
|
| 354 |
for row in 0..<height {
|
| 355 |
let outBase = row * outStride
|
| 356 |
for col in 0..<width {
|
|
@@ -358,12 +426,27 @@ final class DepthPredictor {
|
|
| 358 |
// shifting left by depthHalf means shiftedCol = (col + depthHalf) % width
|
| 359 |
let shiftedCol = (col + depthHalf) % width
|
| 360 |
|
| 361 |
-
if shiftedCol
|
| 362 |
-
//
|
|
|
|
|
|
|
|
|
|
| 363 |
outPtr[outBase + col] = shiftView.value(row: row, col: shiftedCol)
|
| 364 |
} else {
|
| 365 |
-
//
|
| 366 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
}
|
| 368 |
}
|
| 369 |
}
|
|
@@ -512,6 +595,20 @@ final class DepthPredictor {
|
|
| 512 |
|
| 513 |
let compiledURL = try compileModelIfNeeded(at: modelURL)
|
| 514 |
let model = try MLModel(contentsOf: compiledURL, configuration: config)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
visionModel = try VNCoreMLModel(for: model)
|
| 516 |
|
| 517 |
print("[DepthPredictor] Model loaded from \(modelURL.path)")
|
|
@@ -661,6 +758,42 @@ extension DepthPredictor {
|
|
| 661 |
return cgImage
|
| 662 |
}
|
| 663 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 664 |
/// Save a CIImage as a PNG file (renders via CIContext first).
|
| 665 |
static func saveImage(_ ciImage: CIImage, to path: URL) throws {
|
| 666 |
let context = CIContext()
|
|
|
|
| 138 |
private var visionModel: VNCoreMLModel?
|
| 139 |
private var _outputHeight = 512
|
| 140 |
private var _outputWidth = 1024
|
| 141 |
+
private var _modelInputWidth: Int = 0
|
| 142 |
+
private var _modelInputHeight: Int = 0
|
| 143 |
|
| 144 |
var outputHeight: Int { _outputHeight }
|
| 145 |
var outputWidth: Int { _outputWidth }
|
| 146 |
+
/// Model's expected input dimensions, read from the CoreML model's image
|
| 147 |
+
/// constraints at load time. Used to manually resize source images so that
|
| 148 |
+
/// Vision's `.scaleFit` becomes a no-op (no letterboxing, no implicit
|
| 149 |
+
/// bilinear downscale). Zero if the model isn't loaded.
|
| 150 |
+
var modelInputWidth: Int { _modelInputWidth }
|
| 151 |
+
var modelInputHeight: Int { _modelInputHeight }
|
| 152 |
var isLoaded: Bool { visionModel != nil }
|
| 153 |
|
| 154 |
/// Load model dynamically from a .mlpackage or .mlmodelc URL.
|
|
|
|
| 182 |
}
|
| 183 |
|
| 184 |
/// Run a single pass of depth inference on a CGImage.
|
| 185 |
+
///
|
| 186 |
+
/// The image is resized to the model's expected input dimensions using
|
| 187 |
+
/// high-quality interpolation *before* being handed to Vision. This makes
|
| 188 |
+
/// `imageCropAndScaleOption = .scaleFit` effectively a no-op and avoids
|
| 189 |
+
/// two failure modes of letting Vision do the resize:
|
| 190 |
+
/// 1. Letterboxing on inputs whose aspect ratio doesn't exactly match
|
| 191 |
+
/// the model (Vision pads with black, polluting depth predictions).
|
| 192 |
+
/// 2. Implicit bilinear downscale, which loses high-frequency detail
|
| 193 |
+
/// compared to PIL's Lanczos resize used in the Python export script.
|
| 194 |
private func runSingleInference(on cgImage: CGImage, completion: @escaping (DepthResult?) -> Void) {
|
| 195 |
guard let visionModel else {
|
| 196 |
print("[DepthPredictor] Model not loaded")
|
|
|
|
| 198 |
return
|
| 199 |
}
|
| 200 |
|
| 201 |
+
// Pre-resize to exact model input dims (matches Python's PIL resize).
|
| 202 |
+
let prepared: CGImage
|
| 203 |
+
if _modelInputWidth > 0 && _modelInputHeight > 0,
|
| 204 |
+
let resized = DepthPredictor.resizeImage(cgImage,
|
| 205 |
+
toWidth: _modelInputWidth,
|
| 206 |
+
height: _modelInputHeight) {
|
| 207 |
+
prepared = resized
|
| 208 |
+
} else {
|
| 209 |
+
// Fallback: model dims unknown — let Vision handle scaling.
|
| 210 |
+
prepared = cgImage
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
let request = VNCoreMLRequest(model: visionModel) { [weak self] request, error in
|
| 214 |
if let error {
|
| 215 |
print("[DepthPredictor] Inference error: \(error)")
|
|
|
|
| 234 |
|
| 235 |
request.imageCropAndScaleOption = .scaleFit
|
| 236 |
|
| 237 |
+
let handler = VNImageRequestHandler(cgImage: prepared, options: [:])
|
| 238 |
do {
|
| 239 |
try handler.perform([request])
|
| 240 |
} catch {
|
|
|
|
| 266 |
patchHalfWidth: Int = 25,
|
| 267 |
completion: @escaping (DepthResult?) -> Void
|
| 268 |
) {
|
| 269 |
+
// Resize source to model input dims *once*, so both inference passes
|
| 270 |
+
// and the horizontal shift all happen in the same coordinate space.
|
| 271 |
+
// This avoids resampling twice and keeps the shift offset exact in
|
| 272 |
+
// the same pixel grid as the depth output.
|
| 273 |
+
let prepared: CGImage
|
| 274 |
+
if _modelInputWidth > 0 && _modelInputHeight > 0,
|
| 275 |
+
let resized = DepthPredictor.resizeImage(cgImage,
|
| 276 |
+
toWidth: _modelInputWidth,
|
| 277 |
+
height: _modelInputHeight) {
|
| 278 |
+
prepared = resized
|
| 279 |
+
} else {
|
| 280 |
+
prepared = cgImage
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
let imageWidth = prepared.width
|
| 284 |
let half = imageWidth / 2
|
| 285 |
|
| 286 |
// Shift the source image left by half — the seam moves to the center
|
| 287 |
+
guard let shiftedImage = DepthPredictor.shiftImageHorizontally(prepared, by: half) else {
|
| 288 |
print("[DepthPredictor] Failed to shift image for seam fix")
|
| 289 |
completion(nil)
|
| 290 |
return
|
|
|
|
| 298 |
)
|
| 299 |
}
|
| 300 |
|
| 301 |
+
// 1. Infer depth on the (resized) original image
|
| 302 |
+
runSingleInference(on: prepared) { [weak self] originalDepth in
|
| 303 |
guard let self, let originalDepth else {
|
| 304 |
completion(nil)
|
| 305 |
return
|
|
|
|
| 357 |
}
|
| 358 |
}
|
| 359 |
|
| 360 |
+
/// Stitch the seam region using a single output buffer with **feathered**
|
| 361 |
+
/// blending at the patch boundaries — no intermediate copies.
|
| 362 |
+
///
|
| 363 |
+
/// The two inference passes (original and half-shifted) produce slightly
|
| 364 |
+
/// different absolute depth values even where they agree on geometry,
|
| 365 |
+
/// because they're independent forward passes through a non-linear model.
|
| 366 |
+
/// A hard cutover at the patch boundary therefore leaves a visible step.
|
| 367 |
+
/// To avoid this, we linearly blend from original→shifted as the column
|
| 368 |
+
/// enters the patch zone and from shifted→original as it leaves, using a
|
| 369 |
+
/// transition band of `featherWidth` pixels on each side.
|
| 370 |
///
|
| 371 |
+
/// Layout in *shifted* coordinate space (centered at width/2):
|
|
|
|
|
|
|
|
|
|
| 372 |
///
|
| 373 |
+
/// [ original ][ feather ][ shifted ][ feather ][ original ]
|
| 374 |
+
/// ^ ^ ^ ^
|
| 375 |
+
/// patchLeft coreLeft coreRight patchRight
|
| 376 |
+
///
|
| 377 |
+
/// - Outside `[patchLeft, patchRight)`: pure original.
|
| 378 |
+
/// - Inside `[coreLeft, coreRight)`: pure shifted.
|
| 379 |
+
/// - In the two feather bands: linear blend, weight 0→1 across the band.
|
| 380 |
+
///
|
| 381 |
+
/// `featherWidth` is clamped so the feather bands never overlap the core.
|
| 382 |
private func stitchSeamFromShiftedDepth(
|
| 383 |
original: MLMultiArray,
|
| 384 |
shifted: MLMultiArray,
|
| 385 |
width: Int,
|
| 386 |
height: Int,
|
| 387 |
depthHalf: Int,
|
| 388 |
+
patchHalfWidth: Int,
|
| 389 |
+
featherWidth: Int = 12
|
| 390 |
) -> MLMultiArray? {
|
| 391 |
let origView = DepthArrayView(original)
|
| 392 |
let shiftView = DepthArrayView(shifted)
|
|
|
|
| 397 |
let patchLeft = centerX - dx
|
| 398 |
let patchRight = centerX + dx // exclusive
|
| 399 |
|
| 400 |
+
// Clamp feather so the two bands don't overlap (each band must fit
|
| 401 |
+
// within half the patch width, leaving at least one pure-shifted col).
|
| 402 |
+
let maxFeather = max(0, dx - 1)
|
| 403 |
+
let feather = min(max(0, featherWidth), maxFeather)
|
| 404 |
+
let coreLeft = patchLeft + feather
|
| 405 |
+
let coreRight = patchRight - feather // exclusive
|
| 406 |
+
|
| 407 |
// Create output MLMultiArray
|
| 408 |
let output: MLMultiArray
|
| 409 |
do {
|
|
|
|
| 416 |
let outStride = output.strides[2].intValue
|
| 417 |
let outPtr = output.dataPointer.bindMemory(to: Float32.self, capacity: width * height)
|
| 418 |
|
| 419 |
+
// Precompute reciprocal once (avoid div-by-zero when feather == 0).
|
| 420 |
+
let invFeather: Float32 = feather > 0 ? 1.0 / Float32(feather) : 0.0
|
| 421 |
+
|
| 422 |
for row in 0..<height {
|
| 423 |
let outBase = row * outStride
|
| 424 |
for col in 0..<width {
|
|
|
|
| 426 |
// shifting left by depthHalf means shiftedCol = (col + depthHalf) % width
|
| 427 |
let shiftedCol = (col + depthHalf) % width
|
| 428 |
|
| 429 |
+
if shiftedCol < patchLeft || shiftedCol >= patchRight {
|
| 430 |
+
// Outside patch zone — pure original (identity mapping).
|
| 431 |
+
outPtr[outBase + col] = origView.value(row: row, col: col)
|
| 432 |
+
} else if shiftedCol >= coreLeft && shiftedCol < coreRight {
|
| 433 |
+
// Core patch zone — pure shifted.
|
| 434 |
outPtr[outBase + col] = shiftView.value(row: row, col: shiftedCol)
|
| 435 |
} else {
|
| 436 |
+
// Feather band — linear blend.
|
| 437 |
+
// Weight w: 0 at the outer patch edge, 1 at the core edge.
|
| 438 |
+
let w: Float32
|
| 439 |
+
if shiftedCol < coreLeft {
|
| 440 |
+
// Left feather: ramp up as we move right toward coreLeft.
|
| 441 |
+
w = Float32(shiftedCol - patchLeft) * invFeather
|
| 442 |
+
} else {
|
| 443 |
+
// Right feather: ramp down as we move right toward patchRight.
|
| 444 |
+
w = Float32(patchRight - 1 - shiftedCol) * invFeather
|
| 445 |
+
}
|
| 446 |
+
let wClamped = max(0.0 as Float32, min(1.0 as Float32, w))
|
| 447 |
+
let origVal = origView.value(row: row, col: col)
|
| 448 |
+
let shiftVal = shiftView.value(row: row, col: shiftedCol)
|
| 449 |
+
outPtr[outBase + col] = origVal + (shiftVal - origVal) * wClamped
|
| 450 |
}
|
| 451 |
}
|
| 452 |
}
|
|
|
|
| 595 |
|
| 596 |
let compiledURL = try compileModelIfNeeded(at: modelURL)
|
| 597 |
let model = try MLModel(contentsOf: compiledURL, configuration: config)
|
| 598 |
+
|
| 599 |
+
// Capture the model's expected input dimensions so we can resize
|
| 600 |
+
// source images ourselves (avoiding Vision's letterboxing + implicit
|
| 601 |
+
// bilinear downscale). DAP exports use a single ImageType input.
|
| 602 |
+
if let imageInput = model.modelDescription.inputDescriptionsByName.values
|
| 603 |
+
.first(where: { $0.imageConstraint != nil }),
|
| 604 |
+
let constraint = imageInput.imageConstraint {
|
| 605 |
+
_modelInputWidth = constraint.pixelsWide
|
| 606 |
+
_modelInputHeight = constraint.pixelsHigh
|
| 607 |
+
print("[DepthPredictor] Model input: \(_modelInputWidth)x\(_modelInputHeight)")
|
| 608 |
+
} else {
|
| 609 |
+
print("[DepthPredictor] Warning: could not read model input image constraint; manual resize disabled")
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
visionModel = try VNCoreMLModel(for: model)
|
| 613 |
|
| 614 |
print("[DepthPredictor] Model loaded from \(modelURL.path)")
|
|
|
|
| 758 |
return cgImage
|
| 759 |
}
|
| 760 |
|
| 761 |
+
/// Resize a CGImage to exact `(width, height)` using high-quality
|
| 762 |
+
/// interpolation (Lanczos-equivalent on macOS). Returns nil if context
|
| 763 |
+
/// creation fails.
|
| 764 |
+
///
|
| 765 |
+
/// This is used to pre-resize the source image to the model's expected
|
| 766 |
+
/// input dimensions *before* handing off to Vision. Doing so makes
|
| 767 |
+
/// `imageCropAndScaleOption = .scaleFit` a no-op — no letterboxing on
|
| 768 |
+
/// non-matching aspect ratios, and no implicit bilinear downscale.
|
| 769 |
+
static func resizeImage(_ cgImage: CGImage, toWidth width: Int, height: Int) -> CGImage? {
|
| 770 |
+
guard width > 0, height > 0 else { return nil }
|
| 771 |
+
if cgImage.width == width && cgImage.height == height {
|
| 772 |
+
return cgImage
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
| 776 |
+
let bitmapInfo = CGBitmapInfo.byteOrder32Little.rawValue
|
| 777 |
+
| CGImageAlphaInfo.noneSkipLast.rawValue
|
| 778 |
+
|
| 779 |
+
guard let ctx = CGContext(
|
| 780 |
+
data: nil,
|
| 781 |
+
width: width,
|
| 782 |
+
height: height,
|
| 783 |
+
bitsPerComponent: 8,
|
| 784 |
+
bytesPerRow: 0,
|
| 785 |
+
space: colorSpace,
|
| 786 |
+
bitmapInfo: bitmapInfo
|
| 787 |
+
) else {
|
| 788 |
+
print("[DepthPredictor] resizeImage: CGContext creation failed")
|
| 789 |
+
return nil
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
ctx.interpolationQuality = .high
|
| 793 |
+
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
|
| 794 |
+
return ctx.makeImage()
|
| 795 |
+
}
|
| 796 |
+
|
| 797 |
/// Save a CIImage as a PNG file (renders via CIContext first).
|
| 798 |
static func saveImage(_ ciImage: CIImage, to path: URL) throws {
|
| 799 |
let context = CIContext()
|
test/depth.png
ADDED
|
Git LFS Details
|