| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import Foundation |
| import CoreML |
| import CoreImage |
| import AppKit |
|
|
| |
|
|
| |
| struct Gaussians3D { |
| let meanVectors: MLMultiArray |
| let singularValues: MLMultiArray |
| let quaternions: MLMultiArray |
| let colors: MLMultiArray |
| let opacities: MLMultiArray |
| |
| var count: Int { |
| return meanVectors.shape[1].intValue |
| } |
| |
| |
| |
| func computeImportanceScores() -> [Float] { |
| let n = count |
| var scores = [Float](repeating: 0, count: n) |
| |
| let scalePtr = singularValues.dataPointer.assumingMemoryBound(to: Float.self) |
| let opacityPtr = opacities.dataPointer.assumingMemoryBound(to: Float.self) |
| |
| for i in 0..<n { |
| |
| |
| |
| let s0 = scalePtr[i * 3 + 0] |
| let s1 = scalePtr[i * 3 + 1] |
| let s2 = scalePtr[i * 3 + 2] |
| |
| |
| let scaleProduct = s0 * s1 * s2 |
| |
| |
| let opacity = opacityPtr[i] |
| |
| scores[i] = scaleProduct * opacity |
| } |
| |
| return scores |
| } |
| |
| |
| |
| func decimationIndices(keepRatio: Float) -> [Int] { |
| let n = count |
| let keepCount = max(1, Int(Float(n) * keepRatio)) |
| |
| |
| let scores = computeImportanceScores() |
| |
| |
| var indexedScores = scores.enumerated().map { ($0.offset, $0.element) } |
| indexedScores.sort { $0.1 > $1.1 } |
| |
| |
| var keepIndices = indexedScores.prefix(keepCount).map { $0.0 } |
| |
| |
| keepIndices.sort() |
| |
| return keepIndices |
| } |
| } |
|
|
| |
|
|
| |
| func linearRGBToSRGB(_ linear: Float) -> Float { |
| if linear <= 0.0031308 { |
| return linear * 12.92 |
| } else { |
| return 1.055 * pow(linear, 1.0 / 2.4) - 0.055 |
| } |
| } |
|
|
| |
| func rgbToSphericalHarmonics(_ rgb: Float) -> Float { |
| let coeffDegree0 = sqrt(1.0 / (4.0 * Float.pi)) |
| return (rgb - 0.5) / coeffDegree0 |
| } |
|
|
| |
| func inverseSigmoid(_ x: Float) -> Float { |
| let clamped = min(max(x, 1e-6), 1.0 - 1e-6) |
| return log(clamped / (1.0 - clamped)) |
| } |
|
|
| |
|
|
| class SHARPModelRunner { |
| private let model: MLModel |
| private let inputHeight: Int |
| private let inputWidth: Int |
| |
| init(modelPath: URL, inputHeight: Int = 1536, inputWidth: Int = 1536) throws { |
| let config = MLModelConfiguration() |
| config.computeUnits = .all |
| |
| |
| let compiledModelURL = try SHARPModelRunner.compileModelIfNeeded(at: modelPath) |
| |
| self.model = try MLModel(contentsOf: compiledModelURL, configuration: config) |
| self.inputHeight = inputHeight |
| self.inputWidth = inputWidth |
| |
| |
| print("Model inputs: \(model.modelDescription.inputDescriptionsByName.keys.joined(separator: ", "))") |
| print("Model outputs: \(model.modelDescription.outputDescriptionsByName.keys.joined(separator: ", "))") |
| } |
| |
| |
| private static func compileModelIfNeeded(at modelPath: URL) throws -> URL { |
| let fileManager = FileManager.default |
| let pathExtension = modelPath.pathExtension.lowercased() |
| |
| |
| if pathExtension == "mlmodelc" { |
| print("Model is already compiled.") |
| return modelPath |
| } |
| |
| |
| guard pathExtension == "mlpackage" || pathExtension == "mlmodel" else { |
| throw NSError(domain: "SHARPModelRunner", code: 10, |
| userInfo: [NSLocalizedDescriptionKey: "Unsupported model format: \(pathExtension).Use .mlpackage, .mlmodel, or .mlmodelc"]) |
| } |
| |
| |
| let cacheDir = fileManager.temporaryDirectory.appendingPathComponent("SHARPModelCache") |
| try? fileManager.createDirectory(at: cacheDir, withIntermediateDirectories: true) |
| |
| |
| let modelName = modelPath.deletingPathExtension().lastPathComponent |
| let compiledPath = cacheDir.appendingPathComponent("\(modelName).mlmodelc") |
| |
| |
| if fileManager.fileExists(atPath: compiledPath.path) { |
| |
| let sourceAttrs = try fileManager.attributesOfItem(atPath: modelPath.path) |
| let cachedAttrs = try fileManager.attributesOfItem(atPath: compiledPath.path) |
| |
| if let sourceDate = sourceAttrs[.modificationDate] as? Date, |
| let cachedDate = cachedAttrs[.modificationDate] as? Date, |
| cachedDate >= sourceDate { |
| print("Using cached compiled model at \(compiledPath.path)") |
| return compiledPath |
| } else { |
| |
| try? fileManager.removeItem(at: compiledPath) |
| } |
| } |
| |
| |
| print("Compiling model (this may take a moment)...") |
| let startTime = CFAbsoluteTimeGetCurrent() |
| |
| let temporaryCompiledURL = try MLModel.compileModel(at: modelPath) |
| |
| let compileTime = CFAbsoluteTimeGetCurrent() - startTime |
| print("✓ Model compiled in \(String(format: "%.1f", compileTime))s") |
| |
| |
| try? fileManager.removeItem(at: compiledPath) |
| try fileManager.moveItem(at: temporaryCompiledURL, to: compiledPath) |
| |
| print("Compiled model cached at \(compiledPath.path)") |
| return compiledPath |
| } |
| |
| |
| func preprocessImage(at imagePath: URL) throws -> MLMultiArray { |
| guard let nsImage = NSImage(contentsOf: imagePath) else { |
| throw NSError(domain: "SHARPModelRunner", code: 1, |
| userInfo: [NSLocalizedDescriptionKey: "Failed to load image from \(imagePath.path)"]) |
| } |
| |
| guard let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else { |
| throw NSError(domain: "SHARPModelRunner", code: 2, |
| userInfo: [NSLocalizedDescriptionKey: "Failed to convert to CGImage"]) |
| } |
| |
| |
| let ciImage = CIImage(cgImage: cgImage) |
| let context = CIContext() |
| |
| |
| let scaleX = CGFloat(inputWidth) / ciImage.extent.width |
| let scaleY = CGFloat(inputHeight) / ciImage.extent.height |
| let scaledImage = ciImage.transformed(by: CGAffineTransform(scaleX: scaleX, y: scaleY)) |
| |
| |
| guard let resizedCGImage = context.createCGImage(scaledImage, from: CGRect(x: 0, y: 0, |
| width: inputWidth, |
| height: inputHeight)) else { |
| throw NSError(domain: "SHARPModelRunner", code: 3, |
| userInfo: [NSLocalizedDescriptionKey: "Failed to resize image"]) |
| } |
| |
| |
| let imageArray = try MLMultiArray(shape: [1, 3, NSNumber(value: inputHeight), NSNumber(value: inputWidth)], |
| dataType: .float32) |
| |
| let width = resizedCGImage.width |
| let height = resizedCGImage.height |
| let bytesPerPixel = 4 |
| let bytesPerRow = bytesPerPixel * width |
| var pixelData = [UInt8](repeating: 0, count: height * bytesPerRow) |
| |
| let colorSpace = CGColorSpaceCreateDeviceRGB() |
| guard let cgContext = CGContext(data: &pixelData, |
| width: width, |
| height: height, |
| bitsPerComponent: 8, |
| bytesPerRow: bytesPerRow, |
| space: colorSpace, |
| bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { |
| throw NSError(domain: "SHARPModelRunner", code: 4, |
| userInfo: [NSLocalizedDescriptionKey: "Failed to create bitmap context"]) |
| } |
| |
| cgContext.draw(resizedCGImage, in: CGRect(x: 0, y: 0, width: width, height: height)) |
| |
| |
| |
| let ptr = imageArray.dataPointer.assumingMemoryBound(to: Float.self) |
| let channelStride = inputHeight * inputWidth |
| |
| for y in 0..<height { |
| for x in 0..<width { |
| let pixelIndex = y * bytesPerRow + x * bytesPerPixel |
| let r = Float(pixelData[pixelIndex]) / 255.0 |
| let g = Float(pixelData[pixelIndex + 1]) / 255.0 |
| let b = Float(pixelData[pixelIndex + 2]) / 255.0 |
| |
| let spatialIndex = y * inputWidth + x |
| ptr[0 * channelStride + spatialIndex] = r |
| ptr[1 * channelStride + spatialIndex] = g |
| ptr[2 * channelStride + spatialIndex] = b |
| } |
| } |
| |
| return imageArray |
| } |
| |
| |
| func predict(image: MLMultiArray, focalLengthPx: Float) throws -> Gaussians3D { |
| |
| let disparityFactor = focalLengthPx / Float(inputWidth) |
| |
| |
| let disparityArray = try MLMultiArray(shape: [1], dataType: .float32) |
| disparityArray[0] = NSNumber(value: disparityFactor) |
| |
| |
| let inputFeatures = try MLDictionaryFeatureProvider(dictionary: [ |
| "image": MLFeatureValue(multiArray: image), |
| "disparity_factor": MLFeatureValue(multiArray: disparityArray) |
| ]) |
| |
| |
| let output = try model.prediction(from: inputFeatures) |
| |
| |
| let outputNames = Array(model.modelDescription.outputDescriptionsByName.keys) |
| |
| |
| func findOutput(containing keywords: [String]) -> MLMultiArray? { |
| for name in outputNames { |
| let lowercaseName = name.lowercased() |
| for keyword in keywords { |
| if lowercaseName.contains(keyword.lowercased()) { |
| return output.featureValue(for: name)?.multiArrayValue |
| } |
| } |
| } |
| return nil |
| } |
| |
| |
| let meanVectors = output.featureValue(for: "mean_vectors_3d_positions")?.multiArrayValue |
| ?? findOutput(containing: ["mean", "position", "xyz"]) |
| |
| let singularValues = output.featureValue(for: "singular_values_scales")?.multiArrayValue |
| ?? findOutput(containing: ["singular", "scale"]) |
| |
| let quaternions = output.featureValue(for: "quaternions_rotations")?.multiArrayValue |
| ?? findOutput(containing: ["quaternion", "rotation", "rot"]) |
| |
| let colors = output.featureValue(for: "colors_rgb_linear")?.multiArrayValue |
| ?? findOutput(containing: ["color", "rgb"]) |
| |
| let opacities = output.featureValue(for: "opacities_alpha_channel")?.multiArrayValue |
| ?? findOutput(containing: ["opacity", "alpha"]) |
| |
| |
| if meanVectors == nil || singularValues == nil || quaternions == nil || colors == nil || opacities == nil { |
| print("Warning: Could not match all outputs by name.Available outputs: \(outputNames)") |
| |
| |
| if outputNames.count >= 5 { |
| let sortedNames = outputNames.sorted() |
| guard let mv = output.featureValue(for: sortedNames[0])?.multiArrayValue, |
| let sv = output.featureValue(for: sortedNames[1])?.multiArrayValue, |
| let q = output.featureValue(for: sortedNames[2])?.multiArrayValue, |
| let c = output.featureValue(for: sortedNames[3])?.multiArrayValue, |
| let o = output.featureValue(for: sortedNames[4])?.multiArrayValue else { |
| throw NSError(domain: "SHARPModelRunner", code: 5, |
| userInfo: [NSLocalizedDescriptionKey: "Failed to extract model outputs. Available: \(outputNames)"]) |
| } |
| |
| print("Using outputs by sorted order: \(sortedNames)") |
| return Gaussians3D( |
| meanVectors: mv, |
| singularValues: sv, |
| quaternions: q, |
| colors: c, |
| opacities: o |
| ) |
| } |
| |
| throw NSError(domain: "SHARPModelRunner", code: 5, |
| userInfo: [NSLocalizedDescriptionKey: "Failed to extract model outputs.Available: \(outputNames)"]) |
| } |
| |
| return Gaussians3D( |
| meanVectors: meanVectors!, |
| singularValues: singularValues!, |
| quaternions: quaternions!, |
| colors: colors!, |
| opacities: opacities! |
| ) |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| func savePLY(gaussians: Gaussians3D, |
| focalLengthPx: Float, |
| imageShape: (height: Int, width: Int), |
| to outputPath: URL, |
| decimation: Float = 1.0) throws { |
| |
| let imageHeight = imageShape.height |
| let imageWidth = imageShape.width |
| |
| |
| let keepIndices: [Int] |
| let originalCount = gaussians.count |
| |
| if decimation < 1.0 { |
| keepIndices = gaussians.decimationIndices(keepRatio: decimation) |
| print("Decimating: keeping \(keepIndices.count) of \(originalCount) Gaussians (\(String(format: "%.1f", decimation * 100))%)") |
| } else { |
| keepIndices = Array(0..<originalCount) |
| } |
| |
| let numGaussians = keepIndices.count |
| |
| var fileContent = Data() |
| |
| |
| func appendString(_ str: String) { |
| fileContent.append(str.data(using: .ascii)!) |
| } |
| |
| |
| func appendFloat32(_ value: Float) { |
| var v = value |
| fileContent.append(Data(bytes: &v, count: 4)) |
| } |
| |
| |
| func appendInt32(_ value: Int32) { |
| var v = value |
| fileContent.append(Data(bytes: &v, count: 4)) |
| } |
| |
| |
| func appendUInt32(_ value: UInt32) { |
| var v = value |
| fileContent.append(Data(bytes: &v, count: 4)) |
| } |
| |
| |
| func appendUInt8(_ value: UInt8) { |
| var v = value |
| fileContent.append(Data(bytes: &v, count: 1)) |
| } |
| |
| |
| appendString("ply\n") |
| appendString("format binary_little_endian 1.0\n") |
| |
| |
| appendString("element vertex \(numGaussians)\n") |
| appendString("property float x\n") |
| appendString("property float y\n") |
| appendString("property float z\n") |
| appendString("property float f_dc_0\n") |
| appendString("property float f_dc_1\n") |
| appendString("property float f_dc_2\n") |
| appendString("property float opacity\n") |
| appendString("property float scale_0\n") |
| appendString("property float scale_1\n") |
| appendString("property float scale_2\n") |
| appendString("property float rot_0\n") |
| appendString("property float rot_1\n") |
| appendString("property float rot_2\n") |
| appendString("property float rot_3\n") |
| |
| |
| appendString("element extrinsic 16\n") |
| appendString("property float extrinsic\n") |
| |
| |
| appendString("element intrinsic 9\n") |
| appendString("property float intrinsic\n") |
| |
| |
| appendString("element image_size 2\n") |
| appendString("property uint image_size\n") |
| |
| |
| appendString("element frame 2\n") |
| appendString("property int frame\n") |
| |
| |
| appendString("element disparity 2\n") |
| appendString("property float disparity\n") |
| |
| |
| appendString("element color_space 1\n") |
| appendString("property uchar color_space\n") |
| |
| |
| appendString("element version 3\n") |
| appendString("property uchar version\n") |
| |
| appendString("end_header\n") |
| |
| |
| |
| var disparities: [Float] = [] |
| |
| |
| let meanPtr = gaussians.meanVectors.dataPointer.assumingMemoryBound(to: Float.self) |
| let scalePtr = gaussians.singularValues.dataPointer.assumingMemoryBound(to: Float.self) |
| let quatPtr = gaussians.quaternions.dataPointer.assumingMemoryBound(to: Float.self) |
| let colorPtr = gaussians.colors.dataPointer.assumingMemoryBound(to: Float.self) |
| let opacityPtr = gaussians.opacities.dataPointer.assumingMemoryBound(to: Float.self) |
| |
| for i in keepIndices { |
| |
| let x = meanPtr[i * 3 + 0] |
| let y = meanPtr[i * 3 + 1] |
| let z = meanPtr[i * 3 + 2] |
| appendFloat32(x) |
| appendFloat32(y) |
| appendFloat32(z) |
| |
| |
| if z > 1e-6 { |
| disparities.append(1.0 / z) |
| } |
| |
| |
| |
| |
| let colorR = colorPtr[i * 3 + 0] |
| let colorG = colorPtr[i * 3 + 1] |
| let colorB = colorPtr[i * 3 + 2] |
| |
| let srgbR = linearRGBToSRGB(colorR) |
| let srgbG = linearRGBToSRGB(colorG) |
| let srgbB = linearRGBToSRGB(colorB) |
| |
| let sh0 = rgbToSphericalHarmonics(srgbR) |
| let sh1 = rgbToSphericalHarmonics(srgbG) |
| let sh2 = rgbToSphericalHarmonics(srgbB) |
| |
| appendFloat32(sh0) |
| appendFloat32(sh1) |
| appendFloat32(sh2) |
| |
| |
| let opacity = opacityPtr[i] |
| let opacityLogit = inverseSigmoid(opacity) |
| appendFloat32(opacityLogit) |
| |
| |
| let scale0 = scalePtr[i * 3 + 0] |
| let scale1 = scalePtr[i * 3 + 1] |
| let scale2 = scalePtr[i * 3 + 2] |
| |
| appendFloat32(log(max(scale0, 1e-10))) |
| appendFloat32(log(max(scale1, 1e-10))) |
| appendFloat32(log(max(scale2, 1e-10))) |
| |
| |
| let q0 = quatPtr[i * 4 + 0] |
| let q1 = quatPtr[i * 4 + 1] |
| let q2 = quatPtr[i * 4 + 2] |
| let q3 = quatPtr[i * 4 + 3] |
| |
| appendFloat32(q0) |
| appendFloat32(q1) |
| appendFloat32(q2) |
| appendFloat32(q3) |
| } |
| |
| |
| let identity: [Float] = [ |
| 1, 0, 0, 0, |
| 0, 1, 0, 0, |
| 0, 0, 1, 0, |
| 0, 0, 0, 1 |
| ] |
| for val in identity { |
| appendFloat32(val) |
| } |
| |
| |
| let intrinsic: [Float] = [ |
| focalLengthPx, 0, Float(imageWidth) * 0.5, |
| 0, focalLengthPx, Float(imageHeight) * 0.5, |
| 0, 0, 1 |
| ] |
| for val in intrinsic { |
| appendFloat32(val) |
| } |
| |
| |
| appendUInt32(UInt32(imageWidth)) |
| appendUInt32(UInt32(imageHeight)) |
| |
| |
| appendInt32(1) |
| appendInt32(Int32(numGaussians)) |
| |
| |
| disparities.sort() |
| let q10Index = Int(Float(disparities.count) * 0.1) |
| let q90Index = Int(Float(disparities.count) * 0.9) |
| let disparity10 = disparities.isEmpty ? 0.0 : disparities[min(q10Index, disparities.count - 1)] |
| let disparity90 = disparities.isEmpty ? 1.0 : disparities[min(q90Index, disparities.count - 1)] |
| appendFloat32(disparity10) |
| appendFloat32(disparity90) |
| |
| |
| appendUInt8(1) |
| |
| |
| appendUInt8(1) |
| appendUInt8(5) |
| appendUInt8(0) |
| |
| |
| try fileContent.write(to: outputPath) |
| |
| print("✓ Saved PLY with \(numGaussians) Gaussians to \(outputPath.path)") |
| } |
| } |
|
|
| |
|
|
| struct CommandLineArgs { |
| let modelPath: URL |
| let imagePath: URL |
| let outputPath: URL |
| let focalLength: Float |
| let decimation: Float |
| |
| static func parse() -> CommandLineArgs? { |
| let args = CommandLine.arguments |
| |
| var modelPath: URL? |
| var imagePath: URL? |
| var outputPath: URL? |
| var focalLength: Float = 1536.0 |
| var decimation: Float = 1.0 |
| |
| var i = 1 |
| while i < args.count { |
| let arg = args[i] |
| |
| switch arg { |
| case "-m", "--model": |
| i += 1 |
| if i < args.count { |
| modelPath = URL(fileURLWithPath: args[i]) |
| } |
| |
| case "-i", "--input": |
| i += 1 |
| if i < args.count { |
| imagePath = URL(fileURLWithPath: args[i]) |
| } |
| |
| case "-o", "--output": |
| i += 1 |
| if i < args.count { |
| outputPath = URL(fileURLWithPath: args[i]) |
| } |
| |
| case "-f", "--focal-length": |
| i += 1 |
| if i < args.count { |
| focalLength = Float(args[i]) ?? 1536.0 |
| } |
| |
| case "-d", "--decimation": |
| i += 1 |
| if i < args.count { |
| if let value = Float(args[i]) { |
| |
| if value > 1.0 { |
| decimation = value / 100.0 |
| } else { |
| decimation = value |
| } |
| decimation = max(0.01, min(1.0, decimation)) |
| } |
| } |
| |
| case "-h", "--help": |
| printUsage() |
| return nil |
| |
| default: |
| |
| if modelPath == nil { |
| modelPath = URL(fileURLWithPath: arg) |
| } else if imagePath == nil { |
| imagePath = URL(fileURLWithPath: arg) |
| } else if outputPath == nil { |
| outputPath = URL(fileURLWithPath: arg) |
| } else if focalLength == 1536.0 { |
| focalLength = Float(arg) ?? 1536.0 |
| } |
| } |
| |
| i += 1 |
| } |
| |
| guard let model = modelPath, let image = imagePath, let output = outputPath else { |
| printUsage() |
| return nil |
| } |
| |
| return CommandLineArgs( |
| modelPath: model, |
| imagePath: image, |
| outputPath: output, |
| focalLength: focalLength, |
| decimation: decimation |
| ) |
| } |
| |
| static func printUsage() { |
| let execName = CommandLine.arguments[0].components(separatedBy: "/").last ?? "sharp_runner" |
| print(""" |
| Usage: \(execName) [OPTIONS] <model> <input_image> <output.ply> |
| |
| SHARP Model Inference - Generate 3D Gaussian Splats from a single image |
| |
| Arguments: |
| model Path to the SHARP Core ML model (.mlpackage, .mlmodel, or .mlmodelc) |
| input_image Path to input image (PNG, JPEG, etc.) |
| output.ply Path for output PLY file |
| |
| Options: |
| -m, --model PATH Path to Core ML model |
| -i, --input PATH Path to input image |
| -o, --output PATH Path for output PLY file |
| -f, --focal-length FLOAT Focal length in pixels (default: 1536) |
| -d, --decimation FLOAT Decimation ratio 0.0-1.0 or percentage 1-100 (default: 1.0 = keep all) |
| Example: 0.5 or 50 keeps 50% of Gaussians |
| -h, --help Show this help message |
| |
| Examples: |
| # Basic usage |
| \(execName) sharp.mlpackage photo.jpg output.ply |
| |
| # With focal length |
| \(execName) sharp.mlpackage photo.jpg output.ply 768 |
| |
| # With decimation (keep 50% of points) |
| \(execName) -m sharp.mlpackage -i photo.jpg -o output.ply -d 0.5 |
| |
| # With decimation as percentage |
| \(execName) -m sharp.mlpackage -i photo.jpg -o output.ply -d 25 |
| |
| The model will be automatically compiled on first use and cached for subsequent runs. |
| Decimation keeps the most important Gaussians based on scale and opacity. |
| """) |
| } |
| } |
|
|
| |
|
|
| func main() { |
| guard let args = CommandLineArgs.parse() else { |
| exit(1) |
| } |
| |
| do { |
| print("Loading SHARP model from \(args.modelPath.path)...") |
| let runner = try SHARPModelRunner(modelPath: args.modelPath) |
| |
| print("Preprocessing image \(args.imagePath.path)...") |
| let imageArray = try runner.preprocessImage(at: args.imagePath) |
| |
| print("Running inference...") |
| let startTime = CFAbsoluteTimeGetCurrent() |
| let gaussians = try runner.predict(image: imageArray, focalLengthPx: args.focalLength) |
| let inferenceTime = CFAbsoluteTimeGetCurrent() - startTime |
| |
| print("✓ Generated \(gaussians.count) Gaussians in \(String(format: "%.2f", inferenceTime))s") |
| |
| print("Saving PLY file...") |
| try runner.savePLY( |
| gaussians: gaussians, |
| focalLengthPx: args.focalLength, |
| imageShape: (height: 1536, width: 1536), |
| to: args.outputPath, |
| decimation: args.decimation |
| ) |
| |
| print("✓ Complete!") |
| |
| } catch { |
| print("Error: \(error.localizedDescription)") |
| if let nsError = error as NSError? { |
| print("Domain: \(nsError.domain), Code: \(nsError.code)") |
| if let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] as? Error { |
| print("Underlying error: \(underlyingError)") |
| } |
| } |
| exit(1) |
| } |
| } |
|
|
| main() |