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
Files changed (2) hide show
  1. DepthPredictor.swift +149 -16
  2. 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: cgImage, options: [:])
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
- let imageWidth = cgImage.width
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(cgImage, by: half) else {
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: cgImage) { [weak self] originalDepth in
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 — no intermediate copies.
 
 
 
 
 
 
 
 
 
318
  ///
319
- /// For each output pixel (row, col), we determine whether it falls in the
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
- /// This replaces the prior 3-step roll→patch→unroll with 4 temporary arrays.
 
 
 
 
 
 
 
 
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 >= patchLeft && shiftedCol < patchRight {
362
- // This pixel is in the patch zone — use shifted depth
 
 
 
363
  outPtr[outBase + col] = shiftView.value(row: row, col: shiftedCol)
364
  } else {
365
- // Outside patch — use original depth (identity mapping)
366
- outPtr[outBase + col] = origView.value(row: row, col: col)
 
 
 
 
 
 
 
 
 
 
 
 
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

  • SHA256: f5ba2c27ce8bfeb57f18f2b6c480b5f99a9b81bbd0150821635e6b7f6e4111a2
  • Pointer size: 132 Bytes
  • Size of remote file: 1.1 MB