├── .gitignore ├── LICENSE ├── Package.swift ├── README.md ├── Sources ├── CGColorSpace+Convenience.swift ├── CIColor+Extensions.swift ├── CIContext+Async.swift ├── CIContext+EXR.swift ├── CIContext+ExportQuality.swift ├── CIContext+ValueAccess.swift ├── CIImage+Blending.swift ├── CIImage+Lookup.swift ├── CIImage+Text.swift ├── CIImage+Transformation.swift ├── CIImage+ValueInit.swift ├── CIKernel+MetalSource.swift ├── DebugExtensions │ ├── CIImage+DebugProxy.swift │ ├── DebugProxy+DebugPixel.swift │ ├── DebugProxy+Export.swift │ ├── DebugProxy+RenderInfo.swift │ └── DebugProxy+Statistics.swift └── Pixel.swift └── Tests ├── AsyncTests.swift ├── ColorExtensionsTests.swift ├── DebugExtensionTests.swift ├── EXRTests.swift ├── Float16ValueAccessTests.swift ├── Float32ValueAccessTests.swift ├── Helpers.swift ├── ImageLookupTests.swift ├── ImageTransformationTests.swift ├── Resources ├── AllHalfValues.exr ├── Assets.xcassets │ ├── Contents.json │ └── imageInCatalog.imageset │ │ ├── Contents.json │ │ └── imageInCatalog.png └── imageInBundle.png ├── RuntimeMetalKernelTests.swift └── UInt8ValueAccessTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /.swiftpm 4 | /Packages 5 | /*.xcodeproj 6 | xcuserdata/ 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Digital Masterpieces GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | import PackageDescription 3 | 4 | 5 | let package = Package( 6 | name: "CoreImageExtensions", 7 | platforms: [.iOS(.v13), .macOS(.v10_15), .tvOS(.v13)], 8 | products: [ 9 | .library( 10 | name: "CoreImageExtensions", 11 | targets: ["CoreImageExtensions"]), 12 | .library( 13 | name: "CoreImageExtensions-dynamic", 14 | type: .dynamic, 15 | targets: ["CoreImageExtensions"]), 16 | ], 17 | targets: [ 18 | .target( 19 | name: "CoreImageExtensions", 20 | dependencies: [], 21 | path: "Sources", 22 | cxxSettings: [.define("CI_SILENCE_GL_DEPRECATION")]), 23 | .testTarget( 24 | name: "CoreImageExtensionsTests", 25 | dependencies: ["CoreImageExtensions"], 26 | path: "Tests", 27 | resources: [ 28 | .process("Resources") 29 | ]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CoreImageExtensions 2 | 3 | Useful extensions for Apple's Core Image framework. 4 | 5 | ## Async Rendering 6 | Almost all rendering APIs of `CIContext` are synchronous, i.e., they will block the current thread until rendering is done. In many cases, especially when calling from the main thread, this is undesirable. 7 | 8 | We added an extension to `CIContext` that adds `async` versions of all rendering APIs via a wrapping `actor` instance. The actor can be accessed via the `async` property: 9 | ```swift 10 | let cgImage = await context.async.createCGImage(ciImage, from: ciImage.extent) 11 | ``` 12 | 13 | > **_Note:_** 14 | > Though they are already asynchronous, even the APIs for working with `CIRenderDestination`, like `startTask(toRender:to:)`, will profit from using the `async` versions. 15 | > This is because Core Image will perform an analysis of the filter graph that should be applied to the given image _before_ handing the rendering work to the GPU. 16 | > Especially for more complex filter pipelines this analysis can be quite costly and is better performed in a background queue to not block the main thread. 17 | 18 | We also added async alternatives for the `CIRenderDestination`-related APIs that wait for the task execution and return the `CIRenderInfo` object: 19 | ```swift 20 | let info = try await context.async.render(image, from: rect, to: destination, at: point) 21 | let info = try await context.async.render(image, to: destination) 22 | let info = try await context.async.clear(destination) 23 | ``` 24 | 25 | ## Image Lookup 26 | We added a convenience initializer to `CIImage` that you can use to load an image by its name from an asset catalog or from a bundle directly: 27 | ```swift 28 | let image = CIImage(named: "myImage") 29 | ``` 30 | 31 | This provides the same signature as the corresponding `UIImage` method: 32 | ```swift 33 | // on iOS, Catalyst, tvOS 34 | init?(named name: String, in bundle: Bundle? = nil, compatibleWith traitCollection: UITraitCollection? = nil) 35 | 36 | // on macOS 37 | init?(named name: String, in bundle: Bundle? = nil) 38 | ``` 39 | 40 | ## Images with Fixed Values 41 | In Core Image, you can use the `init(color: CIColor)` initializer of `CIImage` to create an image with infinite extent that only contains pixels with the given color. This, however, only allows the creation of images filled with values in [0…1] since `CIColor` clamps values to this range. 42 | 43 | We added two new factory methods on `CIImage` that allow the creation of images filled with arbitrary values: 44 | ```swift 45 | /// Returns a `CIImage` with infinite extent only containing the given pixel value. 46 | static func containing(values: CIVector) -> CIImage? 47 | 48 | /// Returns a `CIImage` with infinite extent only containing the given value in RGB and alpha 1. 49 | /// So `CIImage.containing(42.3)` would result in an image containing the value (42.3, 42.3, 42.3, 1.0) in each pixel. 50 | static func containing(value: Double) -> CIImage? 51 | ``` 52 | 53 | This is useful, for instance, for passing scalar values into blend filters. For instance, this would create a color inversion effect in RGB: 54 | ```swift 55 | var inverted = CIBlendKernel.multiply.apply(foreground: image, background: CIImage.containing(value: -1)!)! 56 | inverted = CIBlendKernel.componentAdd.apply(foreground: inverted, background: CIImage.containing(value: 1)!)! 57 | ``` 58 | 59 | ## Image Value Access 60 | It can be rather complicated to access the actual pixel values of a `CIImage`. The image needs to be rendered first and the resulting bitmap memory needs to be accessed properly. 61 | 62 | We added some convenience methods to `CIContext` to do just that in a one-liner: 63 | ```swift 64 | // get all pixel values of `image` as an array of `SIMD4` values: 65 | let values = context.readUInt8PixelValues(from: image, in: image.extent) 66 | let red: UInt8 = values[42].r // for instance 67 | 68 | // get the value of a specific pixel as a `SIMD4`: 69 | let value = context.readFloat32PixelValue(from: image, at: CGPoint.zero) 70 | let green: Float32 = value.g // for instance 71 | ``` 72 | 73 | These methods come in variants for accessing an area of pixels (in a given `CGRect`) or single pixels (at a given `CGPoint`). 74 | They are also available for three different data types: `UInt8` (the normal 8-bit per channel format, with [0…255] range), `Float32` (aka `float` containing arbitrary values, but colors are usually mapped to [0...1]), and `Float16` (only on iOS). 75 | 76 | > **_Note:_** 77 | > Also available as `async` versions. 78 | 79 | ## OpenEXR Support 80 | [OpenEXR](https://en.wikipedia.org/wiki/OpenEXR) is an open standard for storing arbitrary bitmap data that exceed “normal” image color data, like 32-bit high-dynamic range data or negative floating point values (for instance for height fields). 81 | 82 | Although Image I/O has native support for the EXR format, Core Image doesn’t provide convenience ways for rendering a `CIImage` into EXR. 83 | We added corresponding methods to `CIContext` for EXR export that align with the API provided for the other file formats: 84 | ```swift 85 | // to create a `Data` object containing a 16-bit float EXR representation: 86 | let exrData = try context.exrRepresentation(of: image, format: .RGBAh) 87 | 88 | // to write a 32-bit float representation to an EXR file at `url`: 89 | try context.writeEXRRepresentation(of: image, to: url, format: .RGBAf) 90 | ``` 91 | 92 | For reading EXR files into a `CIImage`, the usual initializers like `CIImage(contentsOf: url)` or `CIImage(named: “myImage.exr”` (see above) can be used. 93 | 94 | > **_Note:_** 95 | > Also available as `async` versions. 96 | 97 | ### OpenEXR Test Images 98 | All EXR test images used in this project have been taken from [here](https://github.com/AcademySoftwareFoundation/openexr-images/). 99 | 100 | ## Image Transformations 101 | We added some convenience methods to `CIImage` to do common affine transformations on an image in a one-liner (instead of working with `CGAffineTransform`): 102 | ```swift 103 | // Scaling the image by the given factors in x- and y-direction. 104 | let scaledImage = image.scaledBy(x: 0.5, y: 2.0) 105 | // Translating the image within the working space by the given amount in x- and y-direction. 106 | let translatedImage = image.translatedBy(dx: 42, dy: -321) 107 | // Moving the image's origin within the working space to the given point. 108 | let movedImage = image.moved(to: CGPoint(x: 50, y: 100)) 109 | // Moving the center of the image's extent to the given point. 110 | let centeredImage = image.centered(in: .zero) 111 | // Adding a padding of clear pixels around the image, effectively increasing its virtual extent. 112 | let paddedImage = image.paddedBy(dx: 20, dy: 60) 113 | ``` 114 | 115 | You can also add rounded (transparent) corners to an image like this: 116 | ```swift 117 | let imageWithRoundedCorners = image.withRoundedCorners(radius: 5) 118 | ``` 119 | 120 | ## Image Composition 121 | We added convenience APIs for compositing two images using different blend kernels (not just `sourceOver`, as in the built-in `CIImage.composited(over:)` API): 122 | ```swift 123 | // Compositing the image over the specified background image using the given blend kernel. 124 | let composition = image.composited(over: otherImage, using: .multiply) 125 | // Compositing the image over the specified background image using the given blend kernel in the given color space. 126 | let composition = image.composited(over: otherImage, using: .softLight, colorSpace: .displayP3ColorSpace) 127 | ``` 128 | 129 | You can also easily colorize an image (i.e., turn all visible pixels into the given color) like this: 130 | ```swift 131 | // Colorizes visible pixels of the image in the given CIColor. 132 | let colorized = image.colorized(with: .red) 133 | ``` 134 | 135 | ## Color Extensions 136 | `CIColor`s usually clamp their component values to `[0...1]`, which is impractical when working with wide gamut and/or extended dynamic range (EDR) colors. 137 | One can work around that by initializing the color with an extended color space that allows component values outside of those bounds. 138 | We added some convenient extensions for initializing colors with (extended) white and color values. Extended colors will be defined in linear sRGB, meaning a value of `1.0` will match the maximum component value in sRGB. Everything beyond is considered wide gamut. 139 | ```swift 140 | // Convenience initializer for standard linear sRGB 50% gray. 141 | let gray = CIColor(white: 0.5) 142 | // A bright EDR white, way outside of the standard sRGB range. 143 | let brightWhite = CIColor(extendedWhite: 2.0) 144 | // A bright red color, way outside of the standard sRGB range. 145 | // It will likely be clipped to the maximum value of the target color space when rendering. 146 | let brightRed = CIColor(extendedRed: 2.0, green: 0.0, blue: 0.0) 147 | ``` 148 | 149 | We also added a convenience property to get a contrast color (either black or white) that is clearly visible when overlayed over the current color. 150 | This can be used, for instance, to colorize text label overlays. 151 | ```swift 152 | // A color that provide a high contrast to `backgroundColor`. 153 | let labelColor = backgroundColor.contrastColor 154 | ``` 155 | 156 | ## Color Space Convenience 157 | A `CGColorSpace` is usually initialized by its name like this `CGColorSpace(name: CGColorSpace.extendedLinearSRGB)`. 158 | This this is rather long, we added some static accessors for the most common color spaces used when working with Core Image for convenience: 159 | ```swift 160 | CGColorSpace.sRGBColorSpace 161 | CGColorSpace.extendedLinearSRGBColorSpace 162 | CGColorSpace.displayP3ColorSpace 163 | CGColorSpace.extendedLinearDisplayP3ColorSpace 164 | CGColorSpace.itur2020ColorSpace 165 | CGColorSpace.extendedLinearITUR2020ColorSpace 166 | CGColorSpace.itur2100HLGColorSpace 167 | CGColorSpace.itur2100PQColorSpace 168 | ``` 169 | 170 | These can be nicely used inline like this: 171 | ```swift 172 | let color = CIColor(red: 1.0, green: 0.5, blue: 0.0, colorSpace: .displayP3ColorSpace) 173 | ``` 174 | 175 | ## Text Generation 176 | Core Image can generate images that contain text using `CITextImageGenerator` and `CIAttributedTextImageGenerator`. 177 | We added extensions to make them much more convenient to use: 178 | ```swift 179 | // Generating a text image with default settings. 180 | let textImage = CIImage.text("This is text") 181 | // Generating a text image with adjust text settings. 182 | let textImage = CIImage.text("This is text", fontName: "Arial", fontSize: 24, color: .white, padding: 10) 183 | // Generating a text image with a `UIFont` or `NSFont`. 184 | let textImage = CIImage.text("This is text", font: someFont, color: .red, padding: 42) 185 | // Generating a text image with an attributed string. 186 | let attributedTextImage = CIImage.attributedText(someAttributedString, padding: 10) 187 | ``` 188 | 189 | ## Runtime Kernel Compilation from Metal Sources 190 | With the legacy Core Image Kernel Language it was possible (and even required) to compile custom kernel routines at runtime from CIKL source strings. 191 | For custom kernels written in Metal the sources needed to be compiled (with specific flags) together with the rest of the sources at build-time. 192 | While this has the huge benefits of compile-time source checking and huge performance improvements at runtime, it also looses some flexibility. 193 | Most notably when it comes to prototyping, since setting up the Core Image Metal build toolchain is rather complicated and loading pre-compiled kernels require some boilerplate code. 194 | 195 | New in iOS 15 and macOS 12, however, is the ability to also compile Metal-based kernels at runtime using the `CIKernel.kernels(withMetalString:)` API. 196 | However, this API requires some type checking and boilerplate code to retrieve an actual `CIKernel` instance of an appropriate type. 197 | So we added the following convenience API to ease the process: 198 | ```swift 199 | let metalKernelCode = """ 200 | #include 201 | using namespace metal; 202 | 203 | [[ stitchable ]] half4 general(coreimage::sampler_h src) { 204 | return src.sample(src.coord()); 205 | } 206 | [[ stitchable ]] half4 otherGeneral(coreimage::sampler_h src) { 207 | return src.sample(src.coord()); 208 | } 209 | [[ stitchable ]] half4 color(coreimage::sample_h src) { 210 | return src; 211 | } 212 | [[ stitchable ]] float2 warp(coreimage::destination dest) { 213 | return dest.coord(); 214 | } 215 | """ 216 | // Load the first kernel that matches the type (CIKernel) from the metal sources. 217 | let generalKernel = try CIKernel.kernel(withMetalString: metalKernelCode) // loads "general" kernel function 218 | // Load the kernel with a specific function name. 219 | let otherGeneralKernel = try CIKernel.kernel(withMetalString: metalKernelCode, kernelName: "otherGeneral") 220 | // Load the first color kernel from the metal sources. 221 | let colorKernel = try CIColorKernel.kernel(withMetalString: metalKernelCode) // loads "color" kernel function 222 | // Load the first warp kernel from the metal sources. 223 | let colorKernel = try CIWarp.kernel(withMetalString: metalKernelCode) // loads "warp" kernel function 224 | ``` 225 | 226 | **⚠️ _Important:_** 227 | There are a few limitations to this API: 228 | - Run-time compilation of Metal kernels is only supported starting from iOS 15 and macOS 12. 229 | - It only works when the Metal kernels are attributed as `[[ stitchable ]]`. 230 | Please refer to [this WWDC talk](https://developer.apple.com/wwdc21/10159) for details. 231 | - It only works when the Metal device used by Core Image supports dynamic libraries. 232 | You can check `MTLDevice.supportsDynamicLibraries` to see if runtime compilation of Metal-based CIKernels is supported. 233 | - `CIBlendKernel` can't be compiled this way, unfortunately. The `CIKernel.kernels(withMetalString:)` API just identifies them as `CIColorKernel` 234 | 235 | If your minimum deployment target doesn't yet support runtime compilation of Metal kernels, you can use the following API instead. 236 | It allows to provide a backup kernel implementation in CIKL what is used on older system where Metal runtime compilation is not supported: 237 | ```swift 238 | let metalKernelCode = """ 239 | #include 240 | using namespace metal; 241 | 242 | [[ stitchable ]] half4 general(coreimage::sampler_h src) { 243 | return src.sample(src.coord()); 244 | } 245 | """ 246 | let ciklKernelCode = """ 247 | kernel vec4 general(sampler src) { 248 | return sample(src, samplerTransform(src, destCoord())); 249 | } 250 | """ 251 | let kernel = try CIKernel.kernel(withMetalString: metalKernelCode, fallbackCIKLString: ciklKernelCode) 252 | ``` 253 | 254 | > **_Note:_** 255 | > It is generally a much better practice to compile Metal CIKernels along with the rest of your and only use runtime compilation as an exception. 256 | > This way the compiler can check your sources at build-time, and initializing a CIKernel at runtime from pre-compiled sources is much faster. 257 | > A notable exception might arise when you need a custom kernel inside a Swift package since CI Metal kernels can't be built with Swift packages (yet). 258 | > But this should only be used as a last resort. 259 | 260 | ## Extensions for Debugging 261 | > **_⚠️ Warning:_** 262 | > The following extensions and APIs are only meant to be used in `DEBUG` mode! 263 | > Some of them access internal Core Image APIs and they are generally not written to fail gracefully. 264 | > That's why they are only available when compiling with `DEBUG` enabled. 265 | 266 | All debugging helps can be accessed through a `DebugProxy` object that can be accessed by calling `ciImage.debug`, which uses the internal debugging `CIContext` from Core Image. 267 | Alternatively, the debug context can also be specified with `ciImage.debug(with: myContext)`. 268 | 269 | ### Rendering an Image and getting Render Info 270 | When using QuickLook to inspect a `CIImage` during debugging, Core Image will first render the image _and it's filter graph_ and preset it together in the preview. 271 | For complex filter graphs, this might produce and undesirably cluttered preview, or even fail to show the QuickLook preview due to the long processing time. 272 | 273 | If you just want to see the rendered image, you can use the following accessor instead that just renders the image and returns it as `CGImage`, which should preview just fine: 274 | ```swift 275 | let cgImage = ciImage.debug.cgImage 276 | ``` 277 | 278 | If you want to know more information of what happens inside of Core Image when rendering the image, you can use the following to obtain a debug `RenderInfo`: 279 | ```swift 280 | let renderInfo = ciImage.debug.render() // with optional outputColorSpace parameter 281 | ``` 282 | The returned `RenderInfo` contains information about the render process: 283 | - `image`: The rendered image itself as `CGImage` 284 | - `renderTask`: The `CIRenderTask`, which describes the optimized rendering graph before it is executed by Core Image. 285 | - `renderInfo`: The `CIRenderInfo`, containing runtime information of the rendering as well as the concatenated filter graph 286 | 287 | There are also additional accessors for getting the different kind of filter graphs, similar to what [`CI_PRINT_TREE`](https://developer.apple.com/wwdc20/10089) can generate: 288 | - `initialGraph`: A `PDFDocument` that shows the rendered image as well as the unoptimized filter graph (similar to `CI_PRINT_TREE 1 pdf`). 289 | - `optimizedGraph`: A `PDFDocument` that shows the optimized filter graph before rendering (the same as `CI_PRINT_TREE 2 pdf`). 290 | - `programGraph`: A `PDFDocument` that shows runtime information about the rendering as well as the concatenated program filter graph (equivalent to `CI_PRINT_TREE 4 pdf`). 291 | 292 | ### Getting Image Statistics 293 | The following API can be used to access some basic statistics about the image (`min`, `max`, and `avg` pixel values): 294 | ```swift 295 | let stats = image.debug.statistics(in: region, colorSpace: .sRGBColorSpace) // both parameters optional 296 | print(stats) 297 | // min: (r: 0.076, g: 0.076, b: 0.076, a: 1.000) 298 | // max: (r: 1.004, g: 1.004, b: 1.004, a: 1.000) 299 | // avg: (r: 0.676, g: 0.671, b: 0.671, a: 1.000) 300 | ``` 301 | Keep in mind that the `colorSpace` influences the pixel values as well as their value range. 302 | If no color space was specified, the debug context's `workingColorSpace` is used. 303 | 304 | ### Exporting an Image and its RenderInfo 305 | [`CI_PRINT_TREE`](https://developer.apple.com/wwdc20/10089) is a great tool for debugging, bit is also has some major downsides: 306 | - It needs to be explicitly enabled before running the application. 307 | - If not configured otherwise, it will generate a lot of filter graphs and it's hard to fine tune it to only log infos about a specific rendering result. 308 | - It does only print results when invoking actual `CIContext` render calls. It's not possible to get infos for arbitrary `CIImage`s. 309 | - The infos are stored in some temporary directory, and especially on iOS it's hard to get them off device for evaluation. 310 | 311 | The following APIs can be used to easily export/save an image at any time: 312 | ```swift 313 | // simple, exporting as 8-bit TIFF in context working space: 314 | ciImage.debug.export()` 315 | // exports the image as file "_09-41-00_image.tiff" 316 | 317 | // ... or with parameters (see method docs for more details): 318 | ciImage.debug.export(filePrefix: "Debug", codec: .jpeg, format: .RGBA8, quality: 0.7, colorSpace: .sRGBColorSpace) 319 | // exports image as file "Debug_09-41-00_image.jpeg" 320 | ``` 321 | 322 | An image's render info can also be exported. 323 | Exporting the render info will export the image as TIFF file and all three render graphs as PDF files: 324 | ```swift 325 | ciImage.debug.render().export() // with optional filePrefix parameter 326 | // Exports the following files: 327 | // _09-41-00_image.tiff 328 | // _09-41-00_initial_graph.pdf 329 | // _09-41-00_optimized_graph.pdf 330 | // _09-41-00_program_graph.pdf 331 | ``` 332 | 333 | On macOS, calling these methods will trigger the system dialog for choosing a directory to save the files to. 334 | On iOS, a system share sheet containing the exported items will open on the main window. 335 | It can be used, for instance, to AirDrop the files to a computer, or for saving to Files. 336 | 337 | The `export` methods can be called for any image at any time, even from a break point action. 338 | 339 | > **_Note:_** 340 | > In case your macOS app is sandboxed, you need to make sure to set the "User Selected File" access entitlement to "Read/Write". 341 | > Otherwise, the app won't have permission to save the debug files to the selected folder. 342 | 343 | ### Other useful Extensions 344 | `CIImage`, `CIRenderTask`, and `CIRenderInfo` now have a `pdfRepresentation` that exposes the internal API that is used to generate the filter graphs for QuickLook and `CI_PRINT_TREE` as `PDFDocument`. 345 | This is useful when QuickLook fails to generate the preview in time for large filter graphs. 346 | Just load the `pdfRepresentation` into a variable and QuickLook it instead. 347 | -------------------------------------------------------------------------------- /Sources/CGColorSpace+Convenience.swift: -------------------------------------------------------------------------------- 1 | import CoreGraphics 2 | 3 | 4 | /// Some useful extensions for convenience access to the most common color spaces needed when working with Core Image. 5 | public extension CGColorSpace { 6 | 7 | /// The standard Red Green Blue (sRGB) color space. 8 | static var sRGBColorSpace: CGColorSpace? { CGColorSpace(name: CGColorSpace.sRGB) } 9 | /// The sRGB color space with a linear transfer function and extended-range values. 10 | static var extendedLinearSRGBColorSpace: CGColorSpace? { CGColorSpace(name: CGColorSpace.extendedLinearSRGB) } 11 | 12 | /// The Display P3 color space. 13 | static var displayP3ColorSpace: CGColorSpace? { CGColorSpace(name: CGColorSpace.displayP3) } 14 | /// The Display P3 color space with a linear transfer function and extended-range values. 15 | static var extendedLinearDisplayP3ColorSpace: CGColorSpace? { CGColorSpace(name: CGColorSpace.extendedLinearDisplayP3) } 16 | 17 | /// The recommendation of the International Telecommunication Union (ITU) Radiocommunication sector for the BT.2020 color space. 18 | static var itur2020ColorSpace: CGColorSpace? { CGColorSpace(name: CGColorSpace.itur_2020) } 19 | /// The recommendation of the International Telecommunication Union (ITU) Radiocommunication sector for the BT.2020 color space, 20 | /// with a linear transfer function and extended range values. 21 | static var extendedLinearITUR2020ColorSpace: CGColorSpace? { CGColorSpace(name: CGColorSpace.extendedLinearITUR_2020) } 22 | 23 | /// The recommendation of the International Telecommunication Union (ITU) Radiocommunication sector for the BT.2100 color space, 24 | /// with the HLG transfer function. 25 | @available(iOS 12.6, macCatalyst 13.1, macOS 10.15.6, tvOS 12.0, watchOS 5.0, *) 26 | static var itur2100HLGColorSpace: CGColorSpace? { 27 | if #available(iOS 14.0, macCatalyst 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) { 28 | return CGColorSpace(name: CGColorSpace.itur_2100_HLG) 29 | } else { 30 | return CGColorSpace(name: CGColorSpace.itur_2020_HLG) 31 | } 32 | } 33 | /// The recommendation of the International Telecommunication Union (ITU) Radiocommunication sector for the BT.2100 color space, 34 | /// with the PQ transfer function. 35 | static var itur2100PQColorSpace: CGColorSpace? { 36 | if #available(iOS 14.0, macCatalyst 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) { 37 | return CGColorSpace(name: CGColorSpace.itur_2100_PQ) 38 | } else { 39 | return CGColorSpace(name: CGColorSpace.itur_2020_PQ_EOTF) 40 | } 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /Sources/CIColor+Extensions.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | 3 | 4 | public extension CIColor { 5 | 6 | /// Initializes a `CIColor` object with the specified `white` component value in all three (RGB) channels. 7 | /// - Parameters: 8 | /// - white: The unpremultiplied component value that should be use for all three color channels. 9 | /// - alpha: The alpha (opacity) component of the color. 10 | /// - colorSpace: The color space in which to create the new color. This color space must conform to the `CGColorSpaceModel.rgb` color space model. 11 | convenience init?(white: CGFloat, alpha: CGFloat = 1.0, colorSpace: CGColorSpace? = nil) { 12 | if let colorSpace = colorSpace { 13 | self.init(red: white, green: white, blue: white, alpha: alpha, colorSpace: colorSpace) 14 | } else { 15 | self.init(red: white, green: white, blue: white, alpha: alpha) 16 | } 17 | } 18 | 19 | /// Initializes a `CIColor` object with the specified extended white component value in all three (RGB) channels. 20 | /// 21 | /// The color will use the extended linear sRGB color space, which allows EDR values outside of the `[0...1]` SDR range. 22 | /// 23 | /// - Parameters: 24 | /// - white: The unpremultiplied component value that should be use for all three color channels. 25 | /// This value can be outside of the `[0...1]` SDR range to create an EDR color. 26 | /// - alpha: The alpha (opacity) component of the color. 27 | convenience init?(extendedWhite white: CGFloat, alpha: CGFloat = 1.0) { 28 | guard let colorSpace = CGColorSpace.extendedLinearSRGBColorSpace else { return nil } 29 | self.init(white: white, alpha: alpha, colorSpace: colorSpace) 30 | } 31 | 32 | /// Initializes a `CIColor` object with the specified extended component values. 33 | /// 34 | /// The color will use the extended linear sRGB color space, which allows EDR values outside of the `[0...1]` SDR range. 35 | /// 36 | /// - Parameters: 37 | /// - r: The unpremultiplied red component value. 38 | /// This value can be outside of the `[0...1]` SDR range to create an EDR color. 39 | /// - g: The unpremultiplied green component value. 40 | /// This value can be outside of the `[0...1]` SDR range to create an EDR color. 41 | /// - b: The unpremultiplied blue component value. 42 | /// This value can be outside of the `[0...1]` SDR range to create an EDR color. 43 | /// - a: The alpha (opacity) component of the color. 44 | convenience init?(extendedRed r: CGFloat, green g: CGFloat, blue b: CGFloat, alpha a: CGFloat = 1.0) { 45 | guard let colorSpace = CGColorSpace.extendedLinearSRGBColorSpace else { return nil } 46 | self.init(red: r, green: g, blue: b, alpha: a, colorSpace: colorSpace) 47 | } 48 | 49 | /// Returns a color that provides a high contrast to the receiver. 50 | /// 51 | /// The returned color is either black or white, depending on which has the better visibility 52 | /// when put over the receiver color. 53 | var contrastOverlayColor: CIColor { 54 | let lightColor = CIColor.white 55 | let darkColor = CIColor.black 56 | 57 | // Calculate luminance based on D65 white point (assuming linear color values). 58 | let luminance = (self.red * 0.2126) + (self.green * 0.7152) + (self.blue * 0.0722) 59 | // Compare against the perceptual luminance midpoint (0.5 after gamma correction). 60 | return (luminance > 0.214) ? darkColor : lightColor 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /Sources/CIContext+Async.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | 3 | 4 | public extension CIContext { 5 | 6 | /// An actor for synchronizing calls to a `CIContext` and executing them in the background. 7 | /// The `Actor` instance associated with a context can be accessed via ``CIContext/async``. 8 | actor Actor { 9 | 10 | fileprivate static var ASSOCIATED_ACTOR_KEY = malloc(1)! 11 | 12 | /// The "wrapped" context instance. 13 | public private(set) weak var context: CIContext! 14 | 15 | 16 | // MARK: - Lifecycle 17 | 18 | /// Creates a new instance. 19 | /// - Parameter ciContext: The `CIContext` to forward all rendering calls to. 20 | fileprivate init(_ ciContext: CIContext) { 21 | self.context = ciContext 22 | } 23 | 24 | 25 | // MARK: - Drawing 26 | 27 | /// Async version of the `CIContext` method with the same signature. 28 | public func draw(_ image: CIImage, in inRect: CGRect, from fromRect: CGRect) { 29 | self.context.draw(image, in: inRect, from: fromRect) 30 | } 31 | 32 | 33 | // MARK: - Direct Render 34 | 35 | /// Async version of the `CIContext` method with the same signature. 36 | public func render(_ image: CIImage, toBitmap data: UnsafeMutableRawPointer, rowBytes: Int, bounds: CGRect, format: CIFormat, colorSpace: CGColorSpace?) { 37 | self.context.render(image, toBitmap: data, rowBytes: rowBytes, bounds: bounds, format: format, colorSpace: colorSpace) 38 | } 39 | 40 | /// Async version of the `CIContext` method with the same signature. 41 | public func render(_ image: CIImage, to surface: IOSurfaceRef, bounds: CGRect, colorSpace: CGColorSpace?) { 42 | self.context.render(image, to: surface, bounds: bounds, colorSpace: colorSpace) 43 | } 44 | 45 | /// Async version of the `CIContext` method with the same signature. 46 | public func render(_ image: CIImage, to buffer: CVPixelBuffer) { 47 | self.context.render(image, to: buffer) 48 | } 49 | 50 | /// Async version of the `CIContext` method with the same signature. 51 | public func render(_ image: CIImage, to buffer: CVPixelBuffer, bounds: CGRect, colorSpace: CGColorSpace?) { 52 | self.context.render(image, to: buffer, bounds: bounds, colorSpace: colorSpace) 53 | } 54 | 55 | /// Async version of the `CIContext` method with the same signature. 56 | public func render(_ image: CIImage, to texture: MTLTexture, commandBuffer: MTLCommandBuffer?, bounds: CGRect, colorSpace: CGColorSpace) { 57 | self.context.render(image, to: texture, commandBuffer: commandBuffer, bounds: bounds, colorSpace: colorSpace) 58 | } 59 | 60 | 61 | // MARK: - CGImage Creation 62 | 63 | /// Async version of the `CIContext` method with the same signature. 64 | public func createCGImage(_ image: CIImage, from fromRect: CGRect) -> CGImage? { 65 | return self.context.createCGImage(image, from: fromRect) 66 | } 67 | 68 | /// Async version of the `CIContext` method with the same signature. 69 | public func createCGImage(_ image: CIImage, from fromRect: CGRect, format: CIFormat, colorSpace: CGColorSpace?) -> CGImage? { 70 | return self.context.createCGImage(image, from: fromRect, format: format, colorSpace: colorSpace) 71 | } 72 | 73 | /// Async version of the `CIContext` method with the same signature. 74 | public func createCGImage(_ image: CIImage, from fromRect: CGRect, format: CIFormat, colorSpace: CGColorSpace?, deferred: Bool) -> CGImage? { 75 | return self.context.createCGImage(image, from: fromRect, format: format, colorSpace: colorSpace, deferred: deferred) 76 | } 77 | 78 | 79 | // MARK: - Creating Data Representations 80 | 81 | /// Async version of the `CIContext` method with the same signature. 82 | public func tiffRepresentation(of image: CIImage, format: CIFormat, colorSpace: CGColorSpace, options: [CIImageRepresentationOption: Any] = [:]) -> Data? { 83 | return self.context.tiffRepresentation(of: image, format: format, colorSpace: colorSpace, options: options) 84 | } 85 | 86 | /// Async version of the `CIContext` method with the same signature. 87 | public func jpegRepresentation(of image: CIImage, colorSpace: CGColorSpace, options: [CIImageRepresentationOption: Any] = [:]) -> Data? { 88 | return self.context.jpegRepresentation(of: image, colorSpace: colorSpace, options: options) 89 | } 90 | 91 | /// Async version of the `CIContext` method with the same signature. 92 | public func heifRepresentation(of image: CIImage, format: CIFormat, colorSpace: CGColorSpace, options: [CIImageRepresentationOption: Any] = [:]) -> Data? { 93 | return self.context.heifRepresentation(of: image, format: format, colorSpace: colorSpace, options: options) 94 | } 95 | 96 | /// Async version of the `CIContext` method with the same signature. 97 | @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) 98 | public func heif10Representation(of image: CIImage, colorSpace: CGColorSpace, options: [CIImageRepresentationOption: Any] = [:]) throws -> Data { 99 | return try self.context.heif10Representation(of: image, colorSpace: colorSpace, options: options) 100 | } 101 | 102 | /// Async version of the `CIContext` method with the same signature. 103 | public func pngRepresentation(of image: CIImage, format: CIFormat, colorSpace: CGColorSpace, options: [CIImageRepresentationOption: Any] = [:]) -> Data? { 104 | return self.context.pngRepresentation(of: image, format: format, colorSpace: colorSpace, options: options) 105 | } 106 | 107 | /// Async version of the `CIContext` method with the same signature. 108 | public func writeTIFFRepresentation(of image: CIImage, to url: URL, format: CIFormat, colorSpace: CGColorSpace, options: [CIImageRepresentationOption: Any] = [:]) throws { 109 | try self.context.writeTIFFRepresentation(of: image, to: url, format: format, colorSpace: colorSpace, options: options) 110 | } 111 | 112 | /// Async version of the `CIContext` method with the same signature. 113 | public func writePNGRepresentation(of image: CIImage, to url: URL, format: CIFormat, colorSpace: CGColorSpace, options: [CIImageRepresentationOption: Any] = [:]) throws { 114 | try self.context.writePNGRepresentation(of: image, to: url, format: format, colorSpace: colorSpace, options: options) 115 | } 116 | 117 | /// Async version of the `CIContext` method with the same signature. 118 | public func writeJPEGRepresentation(of image: CIImage, to url: URL, colorSpace: CGColorSpace, options: [CIImageRepresentationOption: Any] = [:]) throws { 119 | try self.context.writeJPEGRepresentation(of: image, to: url, colorSpace: colorSpace, options: options) 120 | } 121 | 122 | /// Async version of the `CIContext` method with the same signature. 123 | public func writeHEIFRepresentation(of image: CIImage, to url: URL, format: CIFormat, colorSpace: CGColorSpace, options: [CIImageRepresentationOption: Any] = [:]) throws { 124 | try self.context.writeHEIFRepresentation(of: image, to: url, format: format, colorSpace: colorSpace, options: options) 125 | } 126 | 127 | /// Async version of the `CIContext` method with the same signature. 128 | @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) 129 | public func writeHEIF10Representation(of image: CIImage, to url: URL, colorSpace: CGColorSpace, options: [CIImageRepresentationOption: Any] = [:]) throws { 130 | try self.context.writeHEIF10Representation(of: image, to: url, colorSpace: colorSpace, options: options) 131 | } 132 | 133 | 134 | // MARK: Destination APIs 135 | 136 | /// Async version of the `CIContext` method with the same signature. 137 | @discardableResult 138 | public func startTask(toRender image: CIImage, from fromRect: CGRect, to destination: CIRenderDestination, at atPoint: CGPoint) throws -> CIRenderTask { 139 | return try self.context.startTask(toRender: image, from: fromRect, to: destination, at: atPoint) 140 | } 141 | 142 | /// Async version of the `CIContext` method with the same signature. 143 | @discardableResult 144 | public func startTask(toRender image: CIImage, to destination: CIRenderDestination) throws -> CIRenderTask { 145 | return try self.context.startTask(toRender: image, to: destination) 146 | } 147 | 148 | /// Async version of the `CIContext` method with the same signature. 149 | public func prepareRender(_ image: CIImage, from fromRect: CGRect, to destination: CIRenderDestination, at atPoint: CGPoint) throws { 150 | try self.context.prepareRender(image, from: fromRect, to: destination, at: atPoint) 151 | } 152 | 153 | /// Async version of the `CIContext` method with the same signature. 154 | @discardableResult 155 | public func startTask(toClear destination: CIRenderDestination) throws -> CIRenderTask { 156 | return try self.context.startTask(toClear: destination) 157 | } 158 | 159 | /// Analogue to ``startTask(toRender:from:to:at:)``, but this one will wait for the task to finish execution and return the resulting `CIRenderInfo` object. 160 | @discardableResult 161 | public func render(_ image: CIImage, from fromRect: CGRect, to destination: CIRenderDestination, at atPoint: CGPoint) throws -> CIRenderInfo { 162 | let task = try self.startTask(toRender: image, from: fromRect, to: destination, at: atPoint) 163 | return try task.waitUntilCompleted() 164 | } 165 | 166 | /// Analogue to ``startTask(toRender:to:)``, but this one will wait for the task to finish execution and return the resulting `CIRenderInfo` object. 167 | @discardableResult 168 | public func render(_ image: CIImage, to destination: CIRenderDestination) async throws -> CIRenderInfo { 169 | let task = try self.startTask(toRender: image, to: destination) 170 | return try task.waitUntilCompleted() 171 | } 172 | 173 | /// Analogue to ``startTask(toClear:)``, but this one will wait for the task to finish execution and return the resulting `CIRenderInfo` object. 174 | @discardableResult 175 | public func clear(_ destination: CIRenderDestination) throws -> CIRenderInfo { 176 | let task = try self.startTask(toClear: destination) 177 | return try task.waitUntilCompleted() 178 | } 179 | 180 | } 181 | 182 | /// Returns the ``Actor`` instance associated with this context. 183 | /// Calls to the actor will be forwarded to the context, but their execution 184 | /// will be synchronized and happen asynchronous in the background. 185 | var async: Actor { 186 | // check if we already have an Actor created for this context... 187 | if let actor = objc_getAssociatedObject(self, &Actor.ASSOCIATED_ACTOR_KEY) as? Actor { 188 | return actor 189 | // ... otherwise create a new one and safe it as associated object 190 | } else { 191 | let actor = Actor(self) 192 | objc_setAssociatedObject(self, &Actor.ASSOCIATED_ACTOR_KEY, actor, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) 193 | return actor 194 | } 195 | } 196 | 197 | } 198 | -------------------------------------------------------------------------------- /Sources/CIContext+EXR.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | 3 | 4 | public extension CIContext { 5 | 6 | /// Errors that might be caused during image export. 7 | enum ExportError: Error { 8 | case unsupportedExtent(message: String) 9 | case renderingFailure(message: String) 10 | case imageDestinationCreationFailure(message: String) 11 | } 12 | 13 | 14 | /// Renders the image and returns the resulting image data in EXR format. 15 | /// 16 | /// To render an image for export, the image’s contents must not be empty and its extent dimensions must be finite. 17 | /// To export after applying a filter whose output has infinite extent, see the clampedToExtent() method. 18 | /// 19 | /// No options keys are supported at this time. 20 | /// 21 | /// - Note: ⚠️ Due to a bug in Apple's EXR encoder (FB9080694), the image height must be at least 16 pixels! 22 | /// It will cause a BAD_ACCESS otherwise. 23 | /// 24 | /// - Parameters: 25 | /// - image: The image to render. 26 | /// - format: The pixel format for the output image. 27 | /// - colorSpace: The color space in which to render the output image. This color space must conform 28 | /// to either the `CGColorSpaceModel.rgb` or `CGColorSpaceModel.monochrome` model and must be compatible 29 | /// with the specified pixel format. If `nil`, the context's `outputColorSpace` will be used. 30 | /// - options: No options keys are supported at this time. 31 | /// - Returns: A data representation of the rendered image in EXR format. 32 | /// - Throws: A `CIContext.ExportError` if the image data could not be created. 33 | func exrRepresentation(of image: CIImage, format: CIFormat, colorSpace: CGColorSpace? = nil, options: [CIImageRepresentationOption: Any] = [:]) throws -> Data { 34 | let cgImage = try self.createCGImageForEXRExport(of: image, format: format, colorSpace: colorSpace) 35 | 36 | let data = NSMutableData() 37 | guard let destination = CGImageDestinationCreateWithData(data, "com.ilm.openexr-image" as CFString, 1, nil) else { 38 | throw ExportError.imageDestinationCreationFailure(message: "Failed to create an EXR image destination") 39 | } 40 | CGImageDestinationAddImage(destination, cgImage, image.properties as CFDictionary) 41 | CGImageDestinationFinalize(destination) 42 | 43 | return data as Data 44 | } 45 | 46 | /// Renders the image and exports the resulting image data as a file in EXR format. 47 | /// 48 | /// To render an image for export, the image’s contents must not be empty and its extent dimensions must be finite. 49 | /// To export after applying a filter whose output has infinite extent, see the clampedToExtent() method. 50 | /// 51 | /// No options keys are supported at this time. 52 | /// 53 | /// - Note: ⚠️ Due to a bug in Apple's EXR encoder (FB9080694), the image height must be at least 16 pixels! 54 | /// It will cause a BAD_ACCESS otherwise. 55 | /// 56 | /// - Parameters: 57 | /// - image: The image to render. 58 | /// - url: The file URL at which to write the output EXR file. 59 | /// - format: The pixel format for the output image. 60 | /// - colorSpace: The color space in which to render the output image. This color space must conform 61 | /// to either the `CGColorSpaceModel.rgb` or `CGColorSpaceModel.monochrome` model and must be compatible 62 | /// with the specified pixel format. If `nil`, the context's `outputColorSpace` will be used. 63 | /// - options: No options keys are supported at this time. 64 | /// - Throws: A `CIContext.ExportError` if the image could not be written to the file. 65 | func writeEXRRepresentation(of image: CIImage, to url: URL, format: CIFormat, colorSpace: CGColorSpace? = nil, options: [CIImageRepresentationOption: Any] = [:]) throws { 66 | let cgImage = try self.createCGImageForEXRExport(of: image, format: format, colorSpace: colorSpace) 67 | 68 | guard let destination = CGImageDestinationCreateWithURL(url as CFURL, "com.ilm.openexr-image" as CFString, 1, nil) else { 69 | throw ExportError.imageDestinationCreationFailure(message: "Failed to create an EXR image destination") 70 | } 71 | CGImageDestinationAddImage(destination, cgImage, image.properties as CFDictionary) 72 | CGImageDestinationFinalize(destination) 73 | } 74 | 75 | private func createCGImageForEXRExport(of image: CIImage, format: CIFormat, colorSpace: CGColorSpace?) throws -> CGImage { 76 | guard image.extent.height >= 16 else { 77 | throw ExportError.unsupportedExtent(message: "The image's height must be at least 16 pixels due to a bug in Apple's EXR encoder implementation") 78 | } 79 | guard !image.extent.isInfinite else { 80 | throw ExportError.unsupportedExtent(message: "The image's extent must be finite") 81 | } 82 | guard let cgImage = self.createCGImage(image, from: image.extent, format: format, colorSpace: colorSpace) else { 83 | throw ExportError.renderingFailure(message: "Failed to render image") 84 | } 85 | return cgImage 86 | } 87 | 88 | } 89 | 90 | 91 | public extension CIContext.Actor { 92 | 93 | /// Async version of the `CIContext` method with the same signature. 94 | func exrRepresentation(of image: CIImage, format: CIFormat, colorSpace: CGColorSpace? = nil, options: [CIImageRepresentationOption: Any] = [:]) throws -> Data { 95 | return try self.context.exrRepresentation(of: image, format: format, colorSpace: colorSpace, options: options) 96 | } 97 | 98 | /// Async version of the `CIContext` method with the same signature. 99 | func writeEXRRepresentation(of image: CIImage, to url: URL, format: CIFormat, colorSpace: CGColorSpace? = nil, options: [CIImageRepresentationOption: Any] = [:]) throws { 100 | return try self.context.writeEXRRepresentation(of: image, to: url, format: format, colorSpace: colorSpace, options: options) 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /Sources/CIContext+ExportQuality.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | 3 | 4 | public extension CIContext { 5 | 6 | /// Same as ``.heifRepresentation(of:format:colorSpace:options:)``, but the `quality` is a parameter here and doesn't need to be set via `options`. ``CIContext`` 7 | /// The `quality` needs to be between `0.0` (worst) and `1.0` (best). 8 | func heifRepresentation(of image: CIImage, format: CIFormat, colorSpace: CGColorSpace, quality: Float, options: [CIImageRepresentationOption: Any] = [:]) -> Data? { 9 | return self.heifRepresentation(of: image, format: format, colorSpace: colorSpace, options: options.withQuality(quality)) 10 | } 11 | 12 | /// Same as ``writeHEIFRepresentation(of:to:format:colorSpace:options:)``, but the `quality` is a parameter here and doesn't need to be set via `options`. 13 | /// The `quality` needs to be between `0.0` (worst) and `1.0` (best). 14 | func writeHEIFRepresentation(of image: CIImage, to url: URL, format: CIFormat, colorSpace: CGColorSpace, quality: Float, options: [CIImageRepresentationOption: Any] = [:]) throws { 15 | try self.writeHEIFRepresentation(of: image, to: url, format: format, colorSpace: colorSpace, options: options.withQuality(quality)) 16 | } 17 | 18 | /// Same as ``heif10Representation(of:colorSpace:options:)``, but the `quality` is a parameter here and doesn't need to be set via `options`. 19 | /// The `quality` needs to be between `0.0` (worst) and `1.0` (best). 20 | @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, *) 21 | func heif10Representation(of image: CIImage, colorSpace: CGColorSpace, quality: Float, options: [CIImageRepresentationOption: Any] = [:]) throws -> Data? { 22 | return try self.heif10Representation(of: image, colorSpace: colorSpace, options: options.withQuality(quality)) 23 | } 24 | 25 | /// Same as ``writeHEIF10Representation(of:to:colorSpace:options:)``, but the `quality` is a parameter here and doesn't need to be set via `options`. 26 | /// The `quality` needs to be between `0.0` (worst) and `1.0` (best). 27 | @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, *) 28 | func writeHEIF10Representation(of image: CIImage, to url: URL, colorSpace: CGColorSpace, quality: Float, options: [CIImageRepresentationOption: Any] = [:]) throws { 29 | try self.writeHEIF10Representation(of: image, to: url, colorSpace: colorSpace, options: options.withQuality(quality)) 30 | } 31 | 32 | /// Same as ``jpegRepresentation(of:colorSpace:options:)``, but the `quality` is a parameter here and doesn't need to be set via `options`. 33 | /// The `quality` needs to be between `0.0` (worst) and `1.0` (best). 34 | func jpegRepresentation(of image: CIImage, colorSpace: CGColorSpace, quality: Float, options: [CIImageRepresentationOption: Any] = [:]) -> Data? { 35 | return self.jpegRepresentation(of: image, colorSpace: colorSpace, options: options.withQuality(quality)) 36 | } 37 | 38 | /// Same as ``writeJPEGRepresentation(of:to:colorSpace:options:)``, but the `quality` is a parameter here and doesn't need to be set via `options`. 39 | /// The `quality` needs to be between `0.0` (worst) and `1.0` (best). 40 | func writeJPEGRepresentation(of image: CIImage, to url: URL, colorSpace: CGColorSpace, quality: Float, options: [CIImageRepresentationOption: Any] = [:]) throws { 41 | try self.writeJPEGRepresentation(of: image, to: url, colorSpace: colorSpace, options: options.withQuality(quality)) 42 | } 43 | 44 | } 45 | 46 | public extension CIContext.Actor { 47 | 48 | /// Async version of ``.heifRepresentation(of:format:colorSpace:options:)``, but the `quality` is a parameter here and doesn't need to be set via `options`. ``CIContext`` 49 | /// The `quality` needs to be between `0.0` (worst) and `1.0` (best). 50 | func heifRepresentation(of image: CIImage, format: CIFormat, colorSpace: CGColorSpace, quality: Float, options: [CIImageRepresentationOption: Any] = [:]) -> Data? { 51 | return self.context.heifRepresentation(of: image, format: format, colorSpace: colorSpace, quality: quality, options: options) 52 | } 53 | 54 | /// Async version of ``writeHEIFRepresentation(of:to:format:colorSpace:options:)``, but the `quality` is a parameter here and doesn't need to be set via `options`. 55 | /// The `quality` needs to be between `0.0` (worst) and `1.0` (best). 56 | func writeHEIFRepresentation(of image: CIImage, to url: URL, format: CIFormat, colorSpace: CGColorSpace, quality: Float, options: [CIImageRepresentationOption: Any] = [:]) throws { 57 | try self.context.writeHEIFRepresentation(of: image, to: url, format: format, colorSpace: colorSpace, quality: quality, options: options) 58 | } 59 | 60 | /// Async version of ``heif10Representation(of:colorSpace:options:)``, but the `quality` is a parameter here and doesn't need to be set via `options`. 61 | /// The `quality` needs to be between `0.0` (worst) and `1.0` (best). 62 | @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, *) 63 | func heif10Representation(of image: CIImage, colorSpace: CGColorSpace, quality: Float, options: [CIImageRepresentationOption: Any] = [:]) throws -> Data? { 64 | return try self.context.heif10Representation(of: image, colorSpace: colorSpace, quality: quality, options: options) 65 | } 66 | 67 | /// Async version of ``writeHEIF10Representation(of:to:colorSpace:options:)``, but the `quality` is a parameter here and doesn't need to be set via `options`. 68 | /// The `quality` needs to be between `0.0` (worst) and `1.0` (best). 69 | @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, *) 70 | func writeHEIF10Representation(of image: CIImage, to url: URL, colorSpace: CGColorSpace, quality: Float, options: [CIImageRepresentationOption: Any] = [:]) throws { 71 | try self.context.writeHEIF10Representation(of: image, to: url, colorSpace: colorSpace, quality: quality, options: options) 72 | } 73 | 74 | /// Async version of ``jpegRepresentation(of:colorSpace:options:)``, but the `quality` is a parameter here and doesn't need to be set via `options`. 75 | /// The `quality` needs to be between `0.0` (worst) and `1.0` (best). 76 | func jpegRepresentationWithMattes(of image: CIImage, colorSpace: CGColorSpace, quality: Float, options: [CIImageRepresentationOption: Any] = [:]) -> Data? { 77 | return self.context.jpegRepresentation(of: image, colorSpace: colorSpace, quality: quality, options: options) 78 | } 79 | 80 | /// Async version of ``writeJPEGRepresentation(of:to:colorSpace:options:)``, but the `quality` is a parameter here and doesn't need to be set via `options`. 81 | /// The `quality` needs to be between `0.0` (worst) and `1.0` (best). 82 | func writeJPEGRepresentation(of image: CIImage, to url: URL, colorSpace: CGColorSpace, quality: Float, options: [CIImageRepresentationOption: Any] = [:]) throws { 83 | try self.context.writeJPEGRepresentation(of: image, to: url, colorSpace: colorSpace, quality: quality, options: options) 84 | } 85 | 86 | } 87 | 88 | 89 | private extension Dictionary where Key == CIImageRepresentationOption, Value == Any { 90 | 91 | func withQuality(_ quality: Float) -> Self { 92 | var copy = self 93 | copy[CIImageRepresentationOption(rawValue: kCGImageDestinationLossyCompressionQuality as String)] = quality 94 | return copy 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /Sources/CIContext+ValueAccess.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | 3 | 4 | extension CIContext { 5 | 6 | // MARK: - UInt8 7 | 8 | /// Reads the RGBA-channel pixel values in UInt8 format from the given `image` in the given `rect` and returns them as an array. 9 | /// - Parameters: 10 | /// - image: The image to read the pixel values from. 11 | /// - rect: The region that should be read. Must be finite and intersect with the extent of `image`. 12 | /// - colorSpace: The export color space used during rendering. If `nil`, the export color space of the context is used. 13 | /// - Returns: An array containing the UInt8 pixel values. 14 | public func readUInt8PixelValues(from image: CIImage, in rect: CGRect, colorSpace: CGColorSpace? = nil) -> [Pixel] { 15 | return self.readPixelValues(from: image, in: rect, format: .RGBA8, colorSpace: colorSpace, defaultValue: Pixel(repeating: 0)) 16 | } 17 | 18 | /// Reads the RGBA-channel pixel value in UInt8 format from the given `image` at the given `point`. 19 | /// - Parameters: 20 | /// - image: The image to read the pixel values from. 21 | /// - point: The point in image space from which to read the pixel value. Must be within the extent of `image`. 22 | /// - colorSpace: The export color space used during rendering. If `nil`, the export color space of the context is used. 23 | /// - Returns: The UInt8 pixel value. 24 | public func readUInt8PixelValue(from image: CIImage, at point: CGPoint, colorSpace: CGColorSpace? = nil) -> Pixel { 25 | let defaultValue = Pixel(repeating: 0) 26 | let rect = CGRect(origin: point, size: CGSize(width: 1, height: 1)) 27 | let values = self.readUInt8PixelValues(from: image, in: rect, colorSpace: colorSpace) 28 | return values.first ?? defaultValue 29 | } 30 | 31 | 32 | // MARK: - Float32 33 | 34 | /// Reads the RGBA-channel pixel values in Float32 format from the given `image` in the given `rect` and returns them as an array. 35 | /// - Parameters: 36 | /// - image: The image to read the pixel values from. 37 | /// - rect: The region that should be read. Must be finite and intersect with the extent of `image`. 38 | /// - colorSpace: The export color space used during rendering. If `nil`, the export color space of the context is used. 39 | /// - Returns: An array containing the Float32 pixel values. 40 | public func readFloat32PixelValues(from image: CIImage, in rect: CGRect, colorSpace: CGColorSpace? = nil) -> [Pixel] { 41 | return self.readPixelValues(from: image, in: rect, format: .RGBAf, colorSpace: colorSpace, defaultValue: Pixel(repeating: .nan)) 42 | } 43 | 44 | /// Reads the RGBA-channel pixel value in Float32 format from the given `image` at the given `point`. 45 | /// - Parameters: 46 | /// - image: The image to read the pixel values from. 47 | /// - point: The point in image space from which to read the pixel value. Must be within the extent of `image`. 48 | /// - colorSpace: The export color space used during rendering. If `nil`, the export color space of the context is used. 49 | /// - Returns: The Float32 pixel value. 50 | public func readFloat32PixelValue(from image: CIImage, at point: CGPoint, colorSpace: CGColorSpace? = nil) -> Pixel { 51 | let defaultValue = Pixel(repeating: .nan) 52 | let rect = CGRect(origin: point, size: CGSize(width: 1, height: 1)) 53 | let values = self.readFloat32PixelValues(from: image, in: rect, colorSpace: colorSpace) 54 | return values.first ?? defaultValue 55 | } 56 | 57 | 58 | #if (os(iOS) || os(tvOS)) && !targetEnvironment(macCatalyst) 59 | // MARK: - Float16 60 | 61 | /// Reads the RGBA-channel pixel values in Float16 format from the given `image` in the given `rect` and returns them as an array. 62 | /// - Parameters: 63 | /// - image: The image to read the pixel values from. 64 | /// - rect: The region that should be read. Must be finite and intersect with the extent of `image`. 65 | /// - colorSpace: The export color space used during rendering. If `nil`, the export color space of the context is used. 66 | /// - Returns: An array containing the Float16 pixel values. 67 | @available(iOS 14, tvOS 14, *) 68 | @available(macOS, unavailable) 69 | @available(macCatalyst, unavailable) 70 | public func readFloat16PixelValues(from image: CIImage, in rect: CGRect, colorSpace: CGColorSpace? = nil) -> [Pixel] { 71 | return self.readPixelValues(from: image, in: rect, format: .RGBAh, colorSpace: colorSpace, defaultValue: Pixel(repeating: .nan)) 72 | } 73 | 74 | /// Reads the RGBA-channel pixel value in Float16 format from the given `image` at the given `point`. 75 | /// - Parameters: 76 | /// - image: The image to read the pixel values from. 77 | /// - point: The point in image space from which to read the pixel value. Must be within the extent of `image`. 78 | /// - colorSpace: The export color space used during rendering. If `nil`, the export color space of the context is used. 79 | /// - Returns: The Float16 pixel value. 80 | @available(iOS 14, tvOS 14, *) 81 | @available(macOS, unavailable) 82 | @available(macCatalyst, unavailable) 83 | public func readFloat16PixelValue(from image: CIImage, at point: CGPoint, colorSpace: CGColorSpace? = nil) -> Pixel { 84 | let defaultValue = Pixel(repeating: .nan) 85 | let rect = CGRect(origin: point, size: CGSize(width: 1, height: 1)) 86 | let values = self.readFloat16PixelValues(from: image, in: rect, colorSpace: colorSpace) 87 | return values.first ?? defaultValue 88 | } 89 | #endif 90 | 91 | 92 | // MARK: - Internal 93 | 94 | private func readPixelValues(from image: CIImage, in rect: CGRect, format: CIFormat, colorSpace: CGColorSpace?, defaultValue: PixelType) -> [PixelType] { 95 | assert(!rect.isInfinite, "Rect must not be infinite") 96 | assert(image.extent.contains(rect), "The given rect must intersect with the image's extent") 97 | // ⚠️ We only support reading 4-channel pixels right now due to alignment requirements of CIContext's `render` API. 98 | assert([.RGBA8, .RGBAh, .RGBAf].contains(format), "Only 4-channel formats are supported right now") 99 | 100 | var values = Array(repeating: defaultValue, count: Int(rect.width * rect.height)) 101 | let rowBytes = MemoryLayout.size * Int(rect.width) 102 | values.withUnsafeMutableBytes { values in 103 | guard let baseAddress = values.baseAddress else { 104 | assertionFailure("Failed to get pointer to return buffer") 105 | return 106 | } 107 | self.render(image, toBitmap: baseAddress, rowBytes: rowBytes, bounds: rect, format: format, colorSpace: colorSpace) 108 | } 109 | return values 110 | } 111 | 112 | } 113 | 114 | 115 | extension CIContext.Actor { 116 | 117 | /// Async version of the `CIContext` method with the same signature. 118 | public func readUInt8PixelValues(from image: CIImage, in rect: CGRect, colorSpace: CGColorSpace? = nil) -> [Pixel] { 119 | return self.context.readUInt8PixelValues(from: image, in: rect, colorSpace: colorSpace) 120 | } 121 | 122 | /// Async version of the `CIContext` method with the same signature. 123 | public func readUInt8PixelValue(from image: CIImage, at point: CGPoint, colorSpace: CGColorSpace? = nil) -> Pixel { 124 | return self.context.readUInt8PixelValue(from: image, at: point, colorSpace: colorSpace) 125 | } 126 | 127 | /// Async version of the `CIContext` method with the same signature. 128 | public func readFloat32PixelValues(from image: CIImage, in rect: CGRect, colorSpace: CGColorSpace? = nil) -> [Pixel] { 129 | return self.context.readFloat32PixelValues(from: image, in: rect, colorSpace: colorSpace) 130 | } 131 | 132 | /// Async version of the `CIContext` method with the same signature. 133 | public func readFloat32PixelValue(from image: CIImage, at point: CGPoint, colorSpace: CGColorSpace? = nil) -> Pixel { 134 | return self.context.readFloat32PixelValue(from: image, at: point, colorSpace: colorSpace) 135 | } 136 | 137 | #if (os(iOS) || os(tvOS)) && !targetEnvironment(macCatalyst) 138 | 139 | /// Async version of the `CIContext` method with the same signature. 140 | @available(iOS 14, tvOS 14, *) 141 | @available(macOS, unavailable) 142 | @available(macCatalyst, unavailable) 143 | public func readFloat16PixelValues(from image: CIImage, in rect: CGRect, colorSpace: CGColorSpace? = nil) -> [Pixel] { 144 | return self.context.readFloat16PixelValues(from: image, in: rect, colorSpace: colorSpace) 145 | } 146 | 147 | /// Async version of the `CIContext` method with the same signature. 148 | @available(iOS 14, tvOS 14, *) 149 | @available(macOS, unavailable) 150 | @available(macCatalyst, unavailable) 151 | public func readFloat16PixelValue(from image: CIImage, at point: CGPoint, colorSpace: CGColorSpace? = nil) -> Pixel { 152 | return self.context.readFloat16PixelValue(from: image, at: point, colorSpace: colorSpace) 153 | } 154 | 155 | #endif 156 | 157 | } 158 | -------------------------------------------------------------------------------- /Sources/CIImage+Blending.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | 3 | 4 | /// Some convenience methods for compositing/blending and colorizing images. 5 | extension CIImage { 6 | 7 | /// Returns a new image created by compositing the original image over the specified background image 8 | /// using the given blend kernel. 9 | /// 10 | /// The `extent` of the result image will be determined by `extent` of the receiver, 11 | /// the `extent` of the `background` images, and the `blendKernel` used. For most of the 12 | /// built-in blend kernels (as well as custom blend kernels) the result image's 13 | /// `extent` will be the union of the receiver's and background image's extents. 14 | /// 15 | /// - Parameters: 16 | /// - background: An image to serve as the background of the compositing operation. 17 | /// - blendKernel: The `CIBlendKernel` to use for blending the image with the `background`. 18 | /// - Returns: An image object representing the result of the compositing operation. 19 | public func composited(over background: CIImage, using blendKernel: CIBlendKernel) -> CIImage? { 20 | return blendKernel.apply(foreground: self, background: background) 21 | } 22 | 23 | /// Returns a new image created by compositing the original image over the specified background image 24 | /// using the given blend kernel in the specified colorspace. 25 | /// 26 | /// The `extent` of the result image will be determined by `extent` of the receiver, 27 | /// the `extent` of the `background` images, and the `blendKernel` used. For most of the 28 | /// built-in blend kernels (as well as custom blend kernels) the result image's 29 | /// `extent` will be the union of the receiver's and background image's extents. 30 | /// 31 | /// - Parameters: 32 | /// - background: An image to serve as the background of the compositing operation. 33 | /// - blendKernel: The `CIBlendKernel` to use for blending the image with the `background`. 34 | /// - colorSpace: The `CGColorSpace` to perform the blend operation in. 35 | /// - Returns: An image object representing the result of the compositing operation. 36 | public func composited(over background: CIImage, using blendKernel: CIBlendKernel, colorSpace: CGColorSpace) -> CIImage? { 37 | return blendKernel.apply(foreground: self, background: background, colorSpace: colorSpace) 38 | } 39 | 40 | /// Colorizes the image in the given color, i.e., all non-transparent pixels in the receiver will be set to `color`. 41 | /// 42 | /// - Parameter color: The color to override visible pixels of the receiver with. 43 | /// - Returns: The colorized image. 44 | public func colorized(with color: CIColor) -> CIImage? { 45 | if #available(iOS 11.0, macCatalyst 13.1, macOS 10.13, tvOS 11.0, *) { 46 | return CIBlendKernel.sourceAtop.apply(foreground: CIImage(color: color).cropped(to: self.extent), background: self) 47 | } else { 48 | return CIImage(color: color).cropped(to: self.extent).applyingFilter("CISourceAtopCompositing", parameters: [kCIInputBackgroundImageKey: self]) 49 | } 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /Sources/CIImage+Lookup.swift: -------------------------------------------------------------------------------- 1 | import CoreGraphics 2 | import CoreImage 3 | 4 | #if canImport(UIKit) 5 | import UIKit 6 | #elseif canImport(AppKit) 7 | import AppKit 8 | #endif 9 | 10 | 11 | public extension CIImage { 12 | 13 | #if canImport(UIKit) 14 | /// Convenience initializer for loading an image by its name from the given bundle. 15 | /// This will try to load the image from an asset catalog first and will search the bundle 16 | /// directly otherwise. 17 | /// - Parameters: 18 | /// - name: The name of the image. Should contain the file extension for bundle resources. 19 | /// - bundle: The bundle containing the image file or asset catalog. Specify nil to search the app’s main bundle. 20 | /// - traitCollection: The traits associated with the intended environment for the image. Use this parameter to ensure 21 | /// that the correct variant of the image is loaded. If you specify nil, 22 | /// this method uses the traits associated with the main screen. 23 | convenience init?(named name: String, in bundle: Bundle? = nil, compatibleWith traitCollection: UITraitCollection? = nil) { 24 | // on iOS, UIImage handles all the lookup logic automatically, so just use that 25 | if let uiImage = UIImage(named: name, in: bundle, compatibleWith: traitCollection) { 26 | self.init(image: uiImage) 27 | } else { 28 | return nil 29 | } 30 | } 31 | #endif 32 | 33 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) 34 | /// Convenience initializer for loading an image by its name from the given bundle. 35 | /// This will try to load the image from an asset catalog first and will search the bundle 36 | /// directly otherwise. 37 | /// - Parameters: 38 | /// - name: The name of the image. Should contain the file extension for bundle resources. 39 | /// - bundle: The bundle containing the image file or asset catalog. Specify nil to search the app’s main bundle. 40 | convenience init?(named name: String, in bundle: Bundle? = nil) { 41 | let bundle = bundle ?? Bundle.main 42 | // try to load from asset catalog first 43 | if let nsImage = bundle.image(forResource: name), let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) { 44 | self.init(cgImage: cgImage) 45 | // search the bundle directly otherwise 46 | } else if let url = bundle.url(forResource: name, withExtension: nil) { 47 | self.init(contentsOf: url) 48 | } else { 49 | return nil 50 | } 51 | } 52 | #endif 53 | 54 | } 55 | -------------------------------------------------------------------------------- /Sources/CIImage+Text.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | #if canImport(AppKit) 3 | import AppKit 4 | #endif 5 | #if canImport(UIKit) 6 | import UIKit 7 | #endif 8 | 9 | 10 | /// Some convenience methods for rendering text into a `CIImage`. 11 | extension CIImage { 12 | 13 | /// Generates an image that contains the given text. 14 | /// - Parameters: 15 | /// - text: The string of text to render. 16 | /// - fontName: The name of the font that should be used for rendering the text. Defaults to "HelveticaNeue". 17 | /// - fontSize: The size of the font that should be used for rendering the text. Defaults to 12 pt. 18 | /// - color: The color of the text. The background of the text will be transparent. Defaults to black. 19 | /// - padding: A padding to add around the text, effectively increasing the text's virtual `extent`. Default is no padding. 20 | /// - Returns: An image containing the rendered text. 21 | public static func text(_ text: String, fontName: String = "HelveticaNeue", fontSize: CGFloat = 12.0, color: CIColor = .black, padding: CGFloat = 0.0) -> CIImage? { 22 | guard let textGenerator = CIFilter(name: "CITextImageGenerator") else { return nil } 23 | 24 | textGenerator.setValue(fontName, forKey: "inputFontName") 25 | textGenerator.setValue(fontSize, forKey: "inputFontSize") 26 | textGenerator.setValue(text, forKey: "inputText") 27 | if #available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, *) { 28 | // Starting from iOS 16 / macOS 13 we can use the built-in padding property... 29 | textGenerator.setValue(padding, forKey: "inputPadding") 30 | return textGenerator.outputImage?.colorized(with: color) 31 | } else { 32 | // ... otherwise we will do the padding manually. 33 | return textGenerator.outputImage?.colorized(with: color)?.paddedBy(dx: padding, dy: padding).moved(to: .zero) 34 | } 35 | } 36 | 37 | #if canImport(AppKit) 38 | 39 | /// Generates an image that contains the given text. 40 | /// - Parameters: 41 | /// - text: The string of text to render. 42 | /// - font: The `NSFont` that should be used for rendering the text. 43 | /// - color: The color of the text. The background of the text will be transparent. 44 | /// - padding: A padding to add around the text, effectively increasing the text's virtual `extent`. 45 | /// - Returns: An image containing the rendered text. 46 | @available(macOS 10.13, *) 47 | @available(iOS, unavailable) 48 | @available(macCatalyst, unavailable) 49 | @available(tvOS, unavailable) 50 | public static func text(_ text: String, font: NSFont, color: CIColor = .black, padding: CGFloat = 0.0) -> CIImage? { 51 | return self.text(text, fontName: font.fontName, fontSize: font.pointSize, color: color, padding: padding) 52 | } 53 | 54 | #endif 55 | 56 | #if canImport(UIKit) 57 | 58 | /// Generates an image that contains the given text. 59 | /// - Parameters: 60 | /// - text: The string of text to render. 61 | /// - font: The `UIFont` that should be used for rendering the text. 62 | /// - color: The color of the text. The background of the text will be transparent. 63 | /// - padding: A padding to add around the text, effectively increasing the text's virtual `extent`. 64 | /// - Returns: An image containing the rendered text. 65 | public static func text(_ text: String, font: UIFont, color: CIColor = .black, padding: CGFloat = 0.0) -> CIImage? { 66 | return self.text(text, fontName: font.fontName, fontSize: font.pointSize, color: color, padding: padding) 67 | } 68 | 69 | #endif 70 | 71 | /// Generates an image that contains the given attributed text. 72 | /// - Parameters: 73 | /// - attributedText: The `NSAttributedString` to render. 74 | /// - padding: A padding to add around the text, effectively increasing the text's virtual `extent`. 75 | /// - Returns: An image containing the rendered attributed text 76 | public static func attributedText(_ attributedText: NSAttributedString, padding: CGFloat = 0.0) -> CIImage? { 77 | guard let textGenerator = CIFilter(name: "CIAttributedTextImageGenerator") else { return nil } 78 | 79 | textGenerator.setValue(attributedText, forKey: "inputText") 80 | if #available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, *) { 81 | // Starting from iOS 16 / macOS 13 we can use the built-in padding property... 82 | textGenerator.setValue(padding, forKey: "inputPadding") 83 | return textGenerator.outputImage 84 | } else { 85 | // ... otherwise we will do the padding manually. 86 | return textGenerator.outputImage?.paddedBy(dx: padding, dy: padding).moved(to: .zero) 87 | } 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /Sources/CIImage+Transformation.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | 3 | 4 | /// Some useful extensions for performing common transformations on an image. 5 | public extension CIImage { 6 | 7 | /// Returns a new image that represents the original image after scaling it by the given factors in x- and y-direction. 8 | /// 9 | /// The interpolation used for the scaling depends on the technique used by the image. 10 | /// This can be changed by calling `samplingLinear()` or `samplingNearest()` on the image 11 | /// before calling this method. Defaults to (bi)linear scaling when unchanged. 12 | /// 13 | /// - Parameters: 14 | /// - x: The scale factor in x-direction. 15 | /// - y: The scale factor in y-direction. 16 | /// - Returns: A scaled image. 17 | func scaledBy(x: CGFloat, y: CGFloat) -> CIImage { 18 | return self.transformed(by: CGAffineTransform(scaleX: x, y: y)) 19 | } 20 | 21 | /// Returns a new image that represents the original image after translating within the working space 22 | /// by the given amount in x- and y-direction. 23 | /// - Parameters: 24 | /// - dx: The amount to move the image in x-direction. 25 | /// - dy: The amount to move the image in y-direction. 26 | /// - Returns: A moved/translated image. 27 | func translatedBy(dx: CGFloat, dy: CGFloat) -> CIImage { 28 | return self.transformed(by: CGAffineTransform(translationX: dx, y: dy)) 29 | } 30 | 31 | /// Returns a new image that represents the original image after moving its origin within the working space to the given point. 32 | /// - Parameter origin: The new origin point of the image. 33 | /// - Returns: A moved image with the new origin. 34 | func moved(to origin: CGPoint) -> CIImage { 35 | return self.translatedBy(dx: origin.x - self.extent.origin.x, dy: origin.y - self.extent.origin.y) 36 | } 37 | 38 | /// Returns a new image that represents the original image after moving the center of its extent to the given point. 39 | /// - Parameter point: The new center point of the image. 40 | /// - Returns: A moved image with the new center point. 41 | func centered(at point: CGPoint) -> CIImage { 42 | return self.translatedBy(dx: point.x - self.extent.midX, dy: point.y - self.extent.midY) 43 | } 44 | 45 | /// Returns a new image that represents the original image after adding a padding of clear pixels around it, 46 | /// effectively increasing its virtual extent. 47 | /// - Parameters: 48 | /// - dx: The amount of padding to add to the left and right. 49 | /// - dy: The amount of padding to add at the top and bottom. 50 | /// - Returns: A padded image. 51 | func paddedBy(dx: CGFloat, dy: CGFloat) -> CIImage { 52 | let background = CIImage(color: .clear).cropped(to: self.extent.insetBy(dx: -dx, dy: -dy)) 53 | return self.composited(over: background) 54 | } 55 | 56 | /// Returns the same image with rounded corners. The clipped parts of the corner will be transparent. 57 | /// - Parameter radius: The corner radius. 58 | /// - Returns: The same image with rounded corners. 59 | func withRoundedCorners(radius: Double) -> CIImage? { 60 | // We can't apply rounded corners to infinite images. 61 | guard !self.extent.isInfinite else { return self } 62 | 63 | // Generate a white background image with the same extent and rounded corners. 64 | let generator = CIFilter(name: "CIRoundedRectangleGenerator", parameters: [ 65 | kCIInputRadiusKey: radius, 66 | kCIInputExtentKey: CIVector(cgRect: self.extent), 67 | kCIInputColorKey: CIColor.white 68 | ]) 69 | guard let roundedRect = generator?.outputImage else { return nil } 70 | 71 | // Multiply with the image: where the background is white, the resulting color will be that of the image; 72 | // where the background is transparent (the corners), the result will be transparent. 73 | return self.applyingFilter("CIMultiplyCompositing", parameters: [kCIInputBackgroundImageKey: roundedRect]) 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /Sources/CIImage+ValueInit.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | 3 | 4 | public extension CIImage { 5 | 6 | /// Returns a `CIImage` with infinite extent only containing the given pixel value. 7 | /// This is similar to using `init(color:)`, but allows to pass channel values outside 8 | /// the normal [0..1] range. 9 | static func containing(values: CIVector) -> CIImage? { 10 | // use a CIColorMatrix with a clear input image to set the desired 11 | // pixel value via the biasVector, since the biasVector is not clamped to [0..1] 12 | guard let colorMatrixFilter = CIFilter(name: "CIColorMatrix") else { 13 | assertionFailure("Failed to find CIColorMatrix in the system") 14 | return nil 15 | } 16 | colorMatrixFilter.setValue(CIImage(color: CIColor(red: 0, green: 0, blue: 0, alpha: 0)), forKey: kCIInputImageKey) 17 | colorMatrixFilter.setValue(values, forKey: "inputBiasVector") 18 | guard let output = colorMatrixFilter.outputImage else { 19 | assertionFailure("Failed to create image containing values \(values)") 20 | return nil 21 | } 22 | return output 23 | } 24 | 25 | /// Returns a `CIImage` with infinite extent only containing the given value in RGB and alpha 1. 26 | /// So `CIImage.containing(42.3)` would result in an image containing the value (42.3, 42.3, 42.3, 1.0) in each pixel. 27 | static func containing(value: Double) -> CIImage? { 28 | return CIImage.containing(values: CIVector(x: CGFloat(value), y: CGFloat(value), z: CGFloat(value), w: 1.0)) 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /Sources/CIKernel+MetalSource.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | 3 | 4 | @available(iOS 11.0, macCatalyst 13.1, macOS 10.13, tvOS 11.0, *) 5 | public extension CIKernel { 6 | 7 | /// Errors that can be thrown by the Metal kernel runtime compilation APIs. 8 | enum MetalKernelError: Swift.Error { 9 | case functionNotFound(_ message: String) 10 | case noMatchingKernelFound(_ message: String) 11 | case blendKernelsNotSupported(_ message: String) 12 | case ciklKernelCreationFailed(_ message: String) 13 | } 14 | 15 | 16 | /// Compiles a Core Image kernel at runtime from the given Metal `source` string. 17 | /// 18 | /// ⚠️ Important: There are a few limitations to this API: 19 | /// - It only works when the kernels are attributed as `[[ stitchable ]]`. 20 | /// Please refer to [this WWDC talk](https://developer.apple.com/wwdc21/10159) for details. 21 | /// - It only works when the Metal device used by Core Image supports dynamic libraries. 22 | /// You can check ``MTLDevice.supportsDynamicLibraries`` to see if runtime compilation of Metal-based 23 | /// CIKernels is supported. 24 | /// - `CIBlendKernel` can't be compiled this way, unfortunately. The ``CIKernel.kernels(withMetalString:)`` 25 | /// API just identifies them as `CIColorKernel` 26 | /// 27 | /// It is generally a much better practice to compile Metal CIKernels along with the rest of your sources 28 | /// and only use runtime compilation as an exception. This way the compiler can check your sources at 29 | /// build-time, and initializing a CIKernel at runtime from pre-compiled sources is much faster. 30 | /// A notable exception might arise when you need a custom kernel inside a Swift package since CI Metal kernels 31 | /// can't be built with Swift packages (yet). But this should only be used as a last resort. 32 | /// 33 | /// - Parameters: 34 | /// - source: A Metal source code string that contain one or more kernel routines. 35 | /// - kernelName: The name of the kernel function to use for this kernel. Use this if multiple kernels 36 | /// are defined in the source string and you want to load a specific one. Otherwise the 37 | /// first function that matches the kernel type is used. 38 | /// - Returns: The compiled Core Image kernel. 39 | @available(iOS 15.0, macCatalyst 15.0, macOS 12.0, tvOS 15.0, *) 40 | @objc class func kernel(withMetalString source: String, kernelName: String? = nil) throws -> Self { 41 | // Try to compile all kernel routines found in `source`. 42 | let kernels = try CIKernel.kernels(withMetalString: source) 43 | 44 | if let kernelName = kernelName { 45 | // If we were given a specific kernel function name, try to find the kernel with that name that also matches 46 | // the type of the CIKernel (sub-)class (`Self`). 47 | guard let kernel = kernels.first(where: { $0.name == kernelName }), let kernel = kernel as? Self else { 48 | throw MetalKernelError.functionNotFound("No matching kernel function named \"\(kernelName)\" found.") 49 | } 50 | return kernel 51 | } else { 52 | // Otherwise just return the first kernel with a matching kernel type. 53 | guard let kernel = kernels.compactMap({ $0 as? Self }).first else { 54 | throw MetalKernelError.noMatchingKernelFound("No matching kernel of type \(String(reflecting: Self.self)) found.") 55 | } 56 | return kernel 57 | } 58 | } 59 | 60 | /// Compiles a Core Image kernel at runtime from the given Metal `source` string. 61 | /// If this feature is not supported by the OS, the legacy Core Image Kernel Language `ciklSource` is used instead. 62 | /// 63 | /// ⚠️ Important: There are a few limitations to this API: 64 | /// - Run-time compilation of Metal kernels is only supported starting from iOS 15 and macOS 12. 65 | /// If the system doesn't support this feature, the legacy Core Image Kernel Language `ciklSource` is used instead. 66 | /// Note, however, that this API was deprecated with macOS 10.14 and can drop support soon. 67 | /// This API is meant to be used as a temporary solution for when older OSes than iOS 15 and macOS 12 still need to be supported. 68 | /// - It only works when the Metal kernels are attributed as `[[ stitchable ]]`. 69 | /// Please refer to [this WWDC talk](https://developer.apple.com/wwdc21/10159) for details. 70 | /// - It only works when the Metal device used by Core Image supports dynamic libraries. 71 | /// You can check ``MTLDevice.supportsDynamicLibraries`` to see if runtime compilation of Metal-based 72 | /// CIKernels is supported. 73 | /// - `CIBlendKernel` can't be compiled this way, unfortunately. The ``CIKernel.kernels(withMetalString:)`` 74 | /// API just identifies them as `CIColorKernel` 75 | /// 76 | /// It is generally a much better practice to compile Metal CIKernels along with the rest of your sources 77 | /// and only use runtime compilation as an exception. This way the compiler can check your sources at 78 | /// build-time, and initializing a CIKernel at runtime from pre-compiled sources is much faster. 79 | /// A notable exception might arise when you need a custom kernel inside a Swift package since CI Metal kernels 80 | /// can't be built with Swift packages (yet). But this should only be used as a last resort. 81 | /// 82 | /// - Parameters: 83 | /// - source: A Metal source code string that contain one or more kernel routines. 84 | /// - metalKernelName: The name of the kernel function to use for this kernel. Use this if multiple kernels 85 | /// are defined in the source string and you want to load a specific one. Otherwise the 86 | /// first function that matches the kernel type is used. 87 | /// - fallbackCIKLString: The kernel code in the legacy Core Image Kernel Language that is used as a fallback 88 | /// option on older OSes. 89 | /// - Returns: The compiled Core Image kernel. 90 | @objc class func kernel(withMetalString metalSource: String, metalKernelName: String? = nil, fallbackCIKLString ciklSource: String) throws -> Self { 91 | if #available(iOS 15.0, macCatalyst 15.0, macOS 12.0, tvOS 15.0, *) { 92 | return try self.kernel(withMetalString: metalSource, kernelName: metalKernelName) 93 | } else { 94 | guard let fallbackKernel = self.init(source: ciklSource) else { 95 | throw MetalKernelError.ciklKernelCreationFailed("Failed to create fallback kernel from CIKL source string.") 96 | } 97 | return fallbackKernel 98 | } 99 | } 100 | 101 | } 102 | 103 | @available(iOS 11.0, macCatalyst 13.1, macOS 10.13, tvOS 11.0, *) 104 | public extension CIBlendKernel { 105 | 106 | /// ⚠️ `CIBlendKernel` can't be compiled from Metal sources at runtime at the moment. 107 | /// Please see ``CIKernel.kernel(withMetalString:kernelName:)`` for details. 108 | /// You can still compile them using the legacy Core Image Kernel Language and the ``CIBlendKernel.init?(source:)`` API, though. 109 | @available(iOS, unavailable) 110 | @available(macCatalyst, unavailable) 111 | @available(macOS, unavailable) 112 | @available(tvOS, unavailable) 113 | @objc override class func kernel(withMetalString source: String, kernelName: String? = nil) throws -> Self { 114 | throw MetalKernelError.blendKernelsNotSupported("CIBlendKernels can't be initialized with a Metal source string at runtime. Compile them at built-time instead.") 115 | } 116 | 117 | /// ⚠️ `CIBlendKernel` can't be compiled from Metal sources at runtime at the moment. 118 | /// Please see ``CIKernel.kernel(withMetalString:metalKernelName:fallbackCIKLString:)`` for details. 119 | /// You can still compile them using the legacy Core Image Kernel Language and the ``CIBlendKernel.init?(source:)`` API, though. 120 | @available(iOS, unavailable) 121 | @available(macCatalyst, unavailable) 122 | @available(macOS, unavailable) 123 | @available(tvOS, unavailable) 124 | @objc override class func kernel(withMetalString metalSource: String, metalKernelName: String? = nil, fallbackCIKLString ciklSource: String) throws -> Self { 125 | throw MetalKernelError.blendKernelsNotSupported("CIBlendKernels can't be initialized with a Metal source string at runtime. Compile them at built-time instead.") 126 | } 127 | 128 | } 129 | -------------------------------------------------------------------------------- /Sources/DebugExtensions/CIImage+DebugProxy.swift: -------------------------------------------------------------------------------- 1 | #if DEBUG 2 | import CoreImage 3 | 4 | 5 | public extension CIImage { 6 | 7 | /// A proxy object that wraps an `CIImage` and allows to call various methods 8 | /// for debugging and introspection of that image. 9 | struct DebugProxy { 10 | let image: CIImage 11 | let context: CIContext 12 | 13 | init(image: CIImage, context: CIContext? = nil) { 14 | self.image = image 15 | // If no context was given, the internal singleton context that Apple uses 16 | // when generating debugging artifacts. 17 | self.context = context ?? (CIContext.value(forKey: "_singletonContext") as? CIContext) ?? CIContext() 18 | } 19 | } 20 | 21 | 22 | /// Creates a `DebugProxy` object that allows to call various methods 23 | /// for debugging and introspection of the receiver. 24 | /// - Parameter context: The context that is used when rendering the image during debugging 25 | /// tasks. If none was given, the internal singleton context that Apple 26 | /// uses when generating debugging artifacts is used. 27 | /// - Returns: A `DebugProxy` wrapping the receiver. 28 | func debug(with context: CIContext? = nil) -> DebugProxy { 29 | DebugProxy(image: self, context: context) 30 | } 31 | 32 | /// Creates a `DebugProxy` object that allows to call various methods 33 | /// for debugging and introspection of the receiver. 34 | /// It will use the internal singleton `CIContext` that Apple uses when generating debug artifacts. 35 | var debug: DebugProxy { self.debug() } 36 | 37 | } 38 | 39 | #endif 40 | -------------------------------------------------------------------------------- /Sources/DebugExtensions/DebugProxy+DebugPixel.swift: -------------------------------------------------------------------------------- 1 | #if DEBUG 2 | import CoreImage 3 | 4 | 5 | public extension CIImage.DebugProxy { 6 | 7 | /// A wrapper around a `Pixel` value that offers better human-readable 8 | /// description and introspection of its values. 9 | @dynamicMemberLookup 10 | struct DebugPixel: CustomStringConvertible, CustomReflectable { 11 | let value: Pixel 12 | 13 | init(_ value: Pixel) { 14 | self.value = value 15 | } 16 | 17 | // Forward accessors and methods to inner `value`. 18 | subscript(dynamicMember keyPath: KeyPath, T>) -> T { 19 | value[keyPath: keyPath] 20 | } 21 | 22 | /// Formates the pixel component values to 3 fraction digits and with aligned sign prefix. 23 | private var formattedValues: (r: String, g: String, b: String, a: String) { 24 | let formatter = NumberFormatter() 25 | formatter.maximumFractionDigits = 3 26 | formatter.minimumFractionDigits = 3 27 | formatter.positivePrefix = " " 28 | /// Round very small values to zero to avoid `-0.000` values when printing. 29 | let value = self.value.cleaned 30 | let r = formatter.string(from: NSNumber(value: value.r))! 31 | let g = formatter.string(from: NSNumber(value: value.g))! 32 | let b = formatter.string(from: NSNumber(value: value.b))! 33 | let a = formatter.string(from: NSNumber(value: value.a))! 34 | return (r, g, b, a) 35 | } 36 | 37 | public var description: String { 38 | let vals = self.formattedValues 39 | return "(r: \(vals.r), g: \(vals.g), b: \(vals.b), a: \(vals.a))" 40 | } 41 | 42 | public var customMirror: Mirror { 43 | let vals = self.formattedValues 44 | return Mirror(self, 45 | children: ["r": vals.r, "g": vals.g, "b": vals.b, "a": vals.a], 46 | displayStyle: .tuple 47 | ) 48 | } 49 | } 50 | 51 | } 52 | 53 | 54 | private extension Pixel { 55 | /// Rounds very small values to zero to avoid `-0.000` values when printing. 56 | var cleaned: Self { return Self(self.r.cleaned, self.g.cleaned, self.b.cleaned, self.a.cleaned) } 57 | } 58 | 59 | private extension Float32 { 60 | /// Rounds very small values to zero to avoid `-0.000` values when printing. 61 | var cleaned: Self { return (abs(self) < 0.0001) ? 0.0 : self } 62 | } 63 | 64 | #endif 65 | -------------------------------------------------------------------------------- /Sources/DebugExtensions/DebugProxy+Export.swift: -------------------------------------------------------------------------------- 1 | #if DEBUG 2 | 3 | import CoreImage 4 | 5 | 6 | // MARK: - macOS 7 | 8 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) 9 | 10 | import AppKit 11 | 12 | public extension CIImage.DebugProxy { 13 | 14 | /// Renders the image with the given settings and opens a system dialog for picking a folder where to save the image to. 15 | /// - Parameters: 16 | /// - filePrefix: A prefix that is added to the image file. By default, the name of the app is used. 17 | /// - codec: The codec of the exported image. Uses uncompressed TIFF by default. 18 | /// - format: The image format. This influences bit depth (8-, 16-, or 32-bit) and pixel format (uint or float). Defaults to 8-bit uint. 19 | /// Note that not all formats are supported by all codecs. E.g., `jpeg` and `heif` only supports 8-bit formats. 20 | /// - quality: The quality of the exported image, i.e., the amount of compression. Only supported by `jpeg`, `heif`, and `heif10` codecs. 21 | /// - colorSpace: The color space of the exported image. By default, the Display P3 color space is used. 22 | /// Note that it needs to match the chosen `format`, i.e., a single-channel format needs a grayscale color space. 23 | func export(filePrefix: String? = nil, codec: ImageCodec = .tiff, format: CIFormat = .RGBA8, quality: Float = 1.0, colorSpace: CGColorSpace? = nil) { 24 | let filePrefix = exportFilePrefix(filePrefix: filePrefix) 25 | Task { 26 | await openSavePanel(message: "Select folder where to save the image") { url in 27 | let imageURL = url.appendingPathComponent("\(filePrefix)_image").appendingPathExtension(codec.fileExtension) 28 | self.write(to: imageURL, codec: codec, format: format, quality: quality, colorSpace: colorSpace) 29 | } 30 | } 31 | } 32 | 33 | } 34 | 35 | public extension CIImage.DebugProxy.RenderResult { 36 | 37 | /// Opens a system dialog for picking a folder where to export the rendering artifacts 38 | /// (the image as TIFF and various rendering graphs as PDFs) to. 39 | /// - Parameter filePrefix: A prefix that is added to the files. By default, the name of the app is used. 40 | func export(filePrefix: String? = nil) { 41 | let filePrefix = exportFilePrefix(filePrefix: filePrefix) 42 | Task { 43 | await openSavePanel(message: "Select folder where to save the render results") { url in 44 | // Write rendering results into the picked folder. 45 | self.writeResults(to: url, with: filePrefix) 46 | } 47 | } 48 | } 49 | 50 | } 51 | 52 | /// Opens the system panel for selecting a folder to save rendering results to. 53 | /// - Parameters: 54 | /// - message: A message to display on top of the panel. 55 | /// - callback: A callback for writing the files to the chosen URL. 56 | @MainActor private func openSavePanel(message: String, callback: @escaping (URL) -> Void) { 57 | // Use the system panel for picking the folder where to save the files. 58 | let openPanel = NSOpenPanel() 59 | openPanel.message = message 60 | openPanel.prompt = "Select" 61 | openPanel.canChooseFiles = false 62 | openPanel.canChooseDirectories = true 63 | openPanel.canCreateDirectories = true 64 | 65 | openPanel.begin { response in 66 | guard response == .OK else { return } 67 | callback(openPanel.url!) 68 | } 69 | } 70 | 71 | #endif // AppKit 72 | 73 | 74 | // MARK: - iOS 75 | 76 | #if canImport(UIKit) 77 | 78 | import UIKit 79 | 80 | public extension CIImage.DebugProxy { 81 | 82 | /// Renders the image with the given settings and opens a share sheet for exporting the image. 83 | /// - Parameters: 84 | /// - filePrefix: A prefix that is added to the image file. By default, the name of the app is used. 85 | /// - codec: The codec of the exported image. Uses uncompressed TIFF by default. 86 | /// - format: The image format. This influences bit depth (8-, 16-, or 32-bit) and pixel format (uint or float). Defaults to 8-bit uint. 87 | /// Note that not all formats are supported by all codecs. E.g., `jpeg` and `heif` only supports 8-bit formats. 88 | /// - quality: The quality of the exported image, i.e., the amount of compression. Only supported by `jpeg`, `heif`, and `heif10` codecs. 89 | /// - colorSpace: The color space of the exported image. By default, the Display P3 color space is used. 90 | /// Note that it needs to match the chosen `format`, i.e., a single-channel format needs a grayscale color space. 91 | func export(filePrefix: String? = nil, codec: ImageCodec = .tiff, format: CIFormat = .RGBAh, quality: Float = 1.0, colorSpace: CGColorSpace? = nil) { 92 | let filePrefix = exportFilePrefix(filePrefix: filePrefix) 93 | let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(filePrefix)_image").appendingPathExtension(codec.fileExtension) 94 | self.write(to: fileURL, codec: codec, format: format, quality: quality, colorSpace: colorSpace) 95 | Task { 96 | await openShareSheet(for: [fileURL]) 97 | } 98 | } 99 | 100 | } 101 | 102 | public extension CIImage.DebugProxy.RenderResult { 103 | 104 | /// Opens a share sheet for exporting the rendering artifacts (the image as TIFF and various rendering graphs as PDFs). 105 | /// On macOS, the system dialog for picking an export folder will be shown instead. 106 | /// - Parameter filePrefix: A prefix that is added to the files. By default, the name of the app is used. 107 | func export(filePrefix: String? = nil) { 108 | let filePrefix = exportFilePrefix(filePrefix: filePrefix) 109 | let shareItems = self.writeResults(to: FileManager.default.temporaryDirectory, with: filePrefix) 110 | Task { 111 | await openShareSheet(for: shareItems) 112 | } 113 | } 114 | 115 | } 116 | 117 | /// Opens a share sheet (or document picker on Catalyst) for exporting the given files from the application's main window. 118 | /// - Parameter items: A list of files to export. The files will be either moved or deleted after successful export. 119 | @MainActor private func openShareSheet(for items: [URL]) { 120 | let window = UIApplication.shared.windows.first 121 | 122 | if #available(iOS 14, *), ProcessInfo().isMacCatalystApp || ProcessInfo().isiOSAppOnMac { 123 | let documentPicker = UIDocumentPickerViewController(forExporting: items) 124 | window?.rootViewController?.present(documentPicker, animated: true) 125 | } else { 126 | let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil) 127 | activityViewController.completionWithItemsHandler = { _, _, _, _ in 128 | items.forEach { try? FileManager.default.removeItem(at: $0) } 129 | } 130 | activityViewController.popoverPresentationController?.sourceView = window 131 | activityViewController.popoverPresentationController?.sourceRect = CGRect(x: window?.bounds.midX ?? 0, y: 0, width: 1, height: 20) 132 | window?.rootViewController?.present(activityViewController, animated: true) 133 | } 134 | } 135 | 136 | #endif // UIKit 137 | 138 | 139 | // MARK: - Common 140 | 141 | extension CIImage.DebugProxy { 142 | 143 | public enum ImageCodec { 144 | case exr 145 | case jpeg 146 | case heif 147 | @available(iOS 15, macOS 12, macCatalyst 15, *) 148 | case heif10 149 | case png 150 | case tiff 151 | 152 | fileprivate var fileExtension: String { 153 | switch self { 154 | case .exr: return "exr" 155 | case .jpeg: return "jpeg" 156 | case .heif, .heif10: return "heic" 157 | case .png: return "png" 158 | case .tiff: return "tiff" 159 | } 160 | } 161 | } 162 | 163 | private func write(to fileURL: URL, codec: ImageCodec, format: CIFormat = .RGBA8, quality: Float = 1.0, colorSpace: CGColorSpace?) { 164 | let colorSpace = colorSpace ?? .displayP3ColorSpace ?? CGColorSpaceCreateDeviceRGB() 165 | switch codec { 166 | case .exr: 167 | try! self.context.writeEXRRepresentation(of: self.image, to: fileURL, format: format, colorSpace: colorSpace) 168 | case .jpeg: 169 | try! self.context.writeJPEGRepresentation(of: self.image, to: fileURL, colorSpace: colorSpace, quality: quality) 170 | case .heif: 171 | try! self.context.writeHEIFRepresentation(of: self.image, to: fileURL, format: format, colorSpace: colorSpace, quality: quality) 172 | case .heif10: 173 | if #available(iOS 15, macOS 12, macCatalyst 15, *) { 174 | try! self.context.writeHEIF10Representation(of: self.image, to: fileURL, colorSpace: colorSpace, quality: quality) 175 | } 176 | case .png: 177 | try! self.context.writePNGRepresentation(of: self.image, to: fileURL, format: format, colorSpace: colorSpace) 178 | case .tiff: 179 | try! self.context.writeTIFFRepresentation(of: self.image, to: fileURL, format: format, colorSpace: colorSpace) 180 | } 181 | } 182 | 183 | } 184 | 185 | /// Creates a prefix to use for exported files, containing the given `filePrefix` (or the app name if not given) and the current time. 186 | private func exportFilePrefix(filePrefix: String?) -> String { 187 | // The the name of the app as default prefix if possible. 188 | let filePrefix = (filePrefix ?? Bundle.main.infoDictionary?[kCFBundleNameKey as String] as? String)?.appending("_") ?? "" 189 | 190 | // Add the current time to the file name to avoid name clashes. 191 | let dateFormatter = DateFormatter() 192 | dateFormatter.dateFormat = "HH-mm-ss" 193 | let timeString = dateFormatter.string(from: Date()) 194 | return filePrefix.appending(timeString) 195 | } 196 | 197 | private extension CIImage.DebugProxy.RenderResult { 198 | 199 | /// Writes file representations of all rendering result objects (image and various render graphs) 200 | /// into the given `directory` with the given `filePrefix` and returns the URLs of the written files. 201 | @discardableResult func writeResults(to directory: URL, with filePrefix: String) -> [URL] { 202 | let imageURL = directory.appendingPathComponent("\(filePrefix)_image").appendingPathExtension("tiff") 203 | self.image.writeTIFFRepresentation(to: imageURL, with: self.debugProxy.image.properties as CFDictionary) 204 | let initialGraphURL = directory.appendingPathComponent("\(filePrefix)_initial_graph").appendingPathExtension("pdf") 205 | self.initialGraph.write(to: initialGraphURL) 206 | let optimizedGraphURL = directory.appendingPathComponent("\(filePrefix)_optimized_graph").appendingPathExtension("pdf") 207 | self.optimizedGraph.write(to: optimizedGraphURL) 208 | let programGraphURL = directory.appendingPathComponent("\(filePrefix)_program_graph").appendingPathExtension("pdf") 209 | self.programGraph.write(to: programGraphURL) 210 | 211 | return [imageURL, initialGraphURL, optimizedGraphURL, programGraphURL] 212 | } 213 | 214 | } 215 | 216 | #if canImport(MobileCoreServices) 217 | import MobileCoreServices 218 | #else 219 | import CoreServices 220 | #endif 221 | 222 | private extension CGImage { 223 | 224 | /// Writes a TIFF file containing the image with the give metadata `properties` to `url`. 225 | func writeTIFFRepresentation(to url: URL, with properties: CFDictionary) { 226 | let destination = CGImageDestinationCreateWithURL(url as CFURL, kUTTypeTIFF as CFString, 1, nil)! 227 | CGImageDestinationAddImage(destination, self, properties) 228 | CGImageDestinationFinalize(destination) 229 | } 230 | 231 | } 232 | 233 | #endif // DEBUG 234 | -------------------------------------------------------------------------------- /Sources/DebugExtensions/DebugProxy+RenderInfo.swift: -------------------------------------------------------------------------------- 1 | #if DEBUG 2 | import CoreImage 3 | import PDFKit 4 | 5 | 6 | public extension CIImage.DebugProxy { 7 | 8 | /// The result of a debug rendering of an image. 9 | /// 10 | /// It contains the rendered image, the `CIRenderTask` and `CIRenderInfo` that contain the optimized and the 11 | /// program filter graphs, respectively. These graphs are the same as obtained by `CI_PRINT_TREE`. 12 | /// The graphs can be viewed when using Quick Look on the returned `task` or `info` object. 13 | struct RenderResult { 14 | 15 | let debugProxy: CIImage.DebugProxy 16 | 17 | /// The rendered image in 16-bit half float format as `CGImage`. 18 | /// This is the same format that Core Image uses internally during processing (with default settings). 19 | public let image: CGImage 20 | /// The `CIRenderTask` object describing the optimized rendering graph before execution. 21 | public let renderTask: CIRenderTask 22 | /// The `CIRenderInfo` obtained after rendering, containing runtime information as well as the concatenated program filter graph. 23 | public let renderInfo: CIRenderInfo 24 | 25 | /// Shows the rendered image as well as the unoptimized filter graph. 26 | /// This is more or less equivalent to the "initial graph" obtained by `CI_PRINT_TREE`. 27 | public var initialGraph: PDFDocument { self.debugProxy.image.pdfRepresentation } 28 | /// Shows the optimized filter graph, equivalent to the "optimized graph" obtained by `CI_PRINT_TREE`. 29 | public var optimizedGraph: PDFDocument { self.renderTask.pdfRepresentation } 30 | /// Shows runtime information and the program filter graph, equivalent to the concatenated "program graph" obtained by `CI_PRINT_TREE`. 31 | public var programGraph: PDFDocument { self.renderInfo.pdfRepresentation } 32 | 33 | init(debugProxy: CIImage.DebugProxy, image: CGImage, renderTask: CIRenderTask, renderInfo: CIRenderInfo) { 34 | self.debugProxy = debugProxy 35 | self.image = image 36 | self.renderTask = renderTask 37 | self.renderInfo = renderInfo 38 | } 39 | 40 | } 41 | 42 | /// Renders the image into an empty surface and returns all rendering results and infos. 43 | /// - Parameter outputColorSpace: The color space of the resulting image. By default, the `workingColorSpace` of the `context` is used. 44 | /// - Returns: A `RenderingResult` containing all information about the rendering process and 45 | /// its results. 46 | func render(outputColorSpace: CGColorSpace? = nil) -> RenderResult { 47 | if self.image.extent.isInfinite { 48 | fatalError("Can't render an image with infinite extent. You need to crop the image to a finite extent first.") 49 | } 50 | 51 | // Move image to [0, 0] so it matches the destination. 52 | let image = self.image.moved(to: .zero) 53 | 54 | // Use the working color space of the context if non was given. 55 | let outputColorSpace = outputColorSpace ?? self.context.workingColorSpace ?? .extendedLinearSRGBColorSpace! 56 | 57 | // Create a bitmap context so we have some image memory we can render into... 58 | let bitmapContext = CGContext(data: nil, 59 | width: Int(image.extent.width), 60 | height: Int(image.extent.height), 61 | bitsPerComponent: 16, 62 | bytesPerRow: 8 * Int(image.extent.width), 63 | space: outputColorSpace, 64 | bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder16Little.rawValue | CGBitmapInfo.floatComponents.rawValue).rawValue)! 65 | // ... and create a `CIRenderDestination` for rendering into that bitmap memory. 66 | let destination = CIRenderDestination(bitmapData: bitmapContext.data!, 67 | width: bitmapContext.width, 68 | height: bitmapContext.height, 69 | bytesPerRow: bitmapContext.bytesPerRow, 70 | format: .RGBAh) 71 | destination.colorSpace = outputColorSpace 72 | 73 | let task = try! self.context.startTask(toRender: image, to: destination) 74 | let info = try! task.waitUntilCompleted() 75 | let cgImage = bitmapContext.makeImage()! 76 | return RenderResult(debugProxy: self, image: cgImage, renderTask: task, renderInfo: info) 77 | } 78 | 79 | } 80 | 81 | public extension CIImage.DebugProxy { 82 | /// Renders the `image` into a `CGImage` using the `context`'s `workingFormat` and `workingColorSpace` and returns it. 83 | /// 84 | /// You can use this property as an alternative when QuickLook on the `CIImage` fails 85 | /// or when you only want to see the rendered image without the filter graph. 86 | var cgImage: CGImage { 87 | return self.context.createCGImage(self.image, from: self.image.extent, format: self.context.workingFormat, colorSpace: self.context.workingColorSpace)! 88 | } 89 | } 90 | 91 | public extension CIImage { 92 | /// Exposes the internal API used to create the Quick Look representation of a `CIImage` as a `PDFDocument`. 93 | /// This shows the rendered image as well as the unoptimized filter graph. 94 | /// This is more or less equivalent to the "initial graph" obtained by `CI_PRINT_TREE`. 95 | var pdfRepresentation: PDFDocument { 96 | PDFDocument(data: self.value(forKey: "_pdfDataRepresentation") as! Data)! 97 | } 98 | } 99 | 100 | public extension CIRenderTask { 101 | /// Exposes the internal API used to create the Quick Look representation of a `CIRenderTask` as a `PDFDocument`. 102 | /// This shows the optimized filter graph, equivalent to the "optimized graph" obtained by `CI_PRINT_TREE`. 103 | var pdfRepresentation: PDFDocument { 104 | PDFDocument(data: self.value(forKey: "_pdfDataRepresentation") as! Data)! 105 | } 106 | } 107 | 108 | public extension CIRenderInfo { 109 | /// Exposes the internal API used to create the Quick Look representation of a `CIRenderInfo` as a `PDFDocument`. 110 | /// This shows runtime information and the program filter graph, equivalent to the concatenated "program graph" obtained by `CI_PRINT_TREE`. 111 | var pdfRepresentation: PDFDocument { 112 | PDFDocument(data: self.value(forKey: "_pdfDataRepresentation") as! Data)! 113 | } 114 | } 115 | 116 | #endif 117 | -------------------------------------------------------------------------------- /Sources/DebugExtensions/DebugProxy+Statistics.swift: -------------------------------------------------------------------------------- 1 | #if DEBUG 2 | import CoreImage 3 | 4 | 5 | public extension CIImage.DebugProxy { 6 | 7 | /// Basic structure for collecting and displaying basic statistics of an image. 8 | struct ImageStatistics: CustomStringConvertible { 9 | /// The per-component minimum value among pixels. 10 | public let min: DebugPixel 11 | /// The per-component maximum value among pixels. 12 | public let max: DebugPixel 13 | /// The per-component average value among pixels. 14 | public let avg: DebugPixel 15 | 16 | public var description: String { 17 | """ 18 | min: \(self.min) 19 | max: \(self.max) 20 | avg: \(self.avg) 21 | """ 22 | } 23 | 24 | init(min: Pixel, max: Pixel, avg: Pixel) { 25 | self.min = DebugPixel(min) 26 | self.max = DebugPixel(max) 27 | self.avg = DebugPixel(avg) 28 | } 29 | } 30 | 31 | 32 | /// Calculates statistics (per-component minimum, maximum, average) of image pixels. 33 | /// - Parameters: 34 | /// - rect: The area of the image from which to gather the statistics. Defaults to the whole image. 35 | /// - colorSpace: The export color space used during rendering. If `nil`, the export color space of the context is used. 36 | /// - Returns: An `ImageStatistics` containing the statistical values. 37 | func statistics(in rect: CGRect? = nil, colorSpace: CGColorSpace? = nil) -> ImageStatistics { 38 | let rect = rect ?? self.image.extent 39 | guard !rect.isInfinite else { 40 | fatalError("Image extent is infinite. Image statistics can only be gathered in a finite area.") 41 | } 42 | 43 | // Generates a 2x1 px image with min and max values, respectively. 44 | let minMaxImage = self.image.applyingFilter("CIAreaMinMax", parameters: [kCIInputExtentKey: CIVector(cgRect: rect)]) 45 | // Generates a single-pixel image with average values. 46 | let avgImage = self.image.applyingFilter("CIAreaAverage", parameters: [kCIInputExtentKey: CIVector(cgRect: rect)]) 47 | // Extract values and return as `ImageStatistics`. 48 | return ImageStatistics(min: self.context.readFloat32PixelValue(from: minMaxImage, at: CGPoint(x: 0, y: 0), colorSpace: colorSpace), 49 | max: self.context.readFloat32PixelValue(from: minMaxImage, at: CGPoint(x: 1, y: 0), colorSpace: colorSpace), 50 | avg: self.context.readFloat32PixelValue(from: avgImage, at: CGPoint(x: 0, y: 0), colorSpace: colorSpace)) 51 | } 52 | 53 | } 54 | 55 | #endif 56 | -------------------------------------------------------------------------------- /Sources/Pixel.swift: -------------------------------------------------------------------------------- 1 | import simd 2 | 3 | 4 | /// Helper type representing a pixel with RGBA channels. 5 | public typealias Pixel = SIMD4 6 | 7 | public extension Pixel { 8 | 9 | /// The red channel of the pixel. 10 | var r: Scalar { self.x } 11 | /// The green channel of the pixel. 12 | var g: Scalar { self.y } 13 | /// The blue channel of the pixel. 14 | var b: Scalar { self.z } 15 | /// The alpha channel of the pixel. 16 | var a: Scalar { self.w } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /Tests/AsyncTests.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | import CoreImageExtensions 3 | import XCTest 4 | 5 | 6 | class AsyncTests: XCTestCase { 7 | 8 | let context = CIContext() 9 | 10 | let testPixelImage = CIImage.containing(values: CIVector(x: 0.0, y: 0.5, z: 1.0, w: 1.0))!.cropped(to: .singlePixel) 11 | 12 | func testContextRelease() { 13 | var context: CIContext? = CIContext() 14 | // create actor 15 | let _ = context?.async 16 | // create weak reference 17 | weak var weakContext = context 18 | // release context 19 | context = nil 20 | XCTAssertNil(weakContext, "The context should release properly, even after creating the actor.") 21 | } 22 | 23 | func testReadPixelAsync() async { 24 | let value = await self.context.async.readUInt8PixelValue(from: testPixelImage, at: .zero) 25 | XCTAssertEqual(value, Pixel(x: 0, y: 128, z: 255, w: 255)) 26 | } 27 | 28 | func testCreateCGImageAsync() async { 29 | let cgImage = await self.context.async.createCGImage(testPixelImage, from: .singlePixel) 30 | XCTAssertNotNil(cgImage) 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /Tests/ColorExtensionsTests.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | import CoreImageExtensions 3 | import XCTest 4 | 5 | 6 | class ColorExtensionsTests: XCTestCase { 7 | 8 | func testWhite() { 9 | let whiteColor = CIColor(white: 0.42, alpha: 0.21, colorSpace: .itur2020ColorSpace) 10 | 11 | XCTAssertEqual(whiteColor?.red, 0.42) 12 | XCTAssertEqual(whiteColor?.green, 0.42) 13 | XCTAssertEqual(whiteColor?.blue, 0.42) 14 | XCTAssertEqual(whiteColor?.alpha, 0.21) 15 | XCTAssertEqual(whiteColor?.colorSpace, .itur2020ColorSpace) 16 | 17 | let clampedWhite = CIColor(white: 1.5) 18 | XCTAssertEqual(clampedWhite?.red, 1.0) 19 | XCTAssertEqual(clampedWhite?.green, 1.0) 20 | XCTAssertEqual(clampedWhite?.blue, 1.0) 21 | XCTAssertEqual(clampedWhite?.alpha, 1.0) 22 | } 23 | 24 | func testExtendedWhite() { 25 | let extendedWhiteColor = CIColor(extendedWhite: 1.42, alpha: 0.34) 26 | 27 | XCTAssertEqual(extendedWhiteColor?.red, 1.42) 28 | XCTAssertEqual(extendedWhiteColor?.green, 1.42) 29 | XCTAssertEqual(extendedWhiteColor?.blue, 1.42) 30 | XCTAssertEqual(extendedWhiteColor?.alpha, 0.34) 31 | XCTAssertEqual(extendedWhiteColor?.colorSpace, .extendedLinearSRGBColorSpace) 32 | } 33 | 34 | func testExtendedColor() { 35 | let extendedColor = CIColor(extendedRed: -0.12, green: 0.43, blue: 1.45, alpha: 0.89) 36 | 37 | XCTAssertEqual(extendedColor?.red, -0.12) 38 | XCTAssertEqual(extendedColor?.green, 0.43) 39 | XCTAssertEqual(extendedColor?.blue, 1.45) 40 | XCTAssertEqual(extendedColor?.alpha, 0.89) 41 | XCTAssertEqual(extendedColor?.colorSpace, .extendedLinearSRGBColorSpace) 42 | } 43 | 44 | func testContrastColor() { 45 | XCTAssertEqual(CIColor.white.contrastOverlayColor, .black) 46 | XCTAssertEqual(CIColor.red.contrastOverlayColor, .white) 47 | XCTAssertEqual(CIColor.green.contrastOverlayColor, .black) 48 | XCTAssertEqual(CIColor.blue.contrastOverlayColor, .white) 49 | XCTAssertEqual(CIColor.yellow.contrastOverlayColor, .black) 50 | XCTAssertEqual(CIColor.cyan.contrastOverlayColor, .black) 51 | XCTAssertEqual(CIColor.magenta.contrastOverlayColor, .black) 52 | XCTAssertEqual(CIColor.black.contrastOverlayColor, .white) 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /Tests/DebugExtensionTests.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | import CoreImageExtensions 3 | import XCTest 4 | 5 | 6 | class DebugExtensionTests: XCTestCase { 7 | 8 | /// A simple test image containing a colorful circle. 9 | let testImage = CIFilter(name: "CIHueSaturationValueGradient", parameters: [kCIInputRadiusKey: 50])!.outputImage! 10 | 11 | 12 | func testImageStatistics() { 13 | let wholeStats = testImage.debug.statistics() 14 | XCTAssertEqual(wholeStats.description, 15 | """ 16 | min: (r: 0.000, g: 0.000, b: 0.000, a: 0.000) 17 | max: (r: 1.004, g: 1.004, b: 1.004, a: 1.000) 18 | avg: (r: 0.423, g: 0.423, b: 0.423, a: 0.770) 19 | """) 20 | 21 | let areaStats = testImage.debug.statistics(in: CGRect(x: 25, y: 25, width: 50, height: 50), colorSpace: .sRGBColorSpace) 22 | XCTAssertEqual(areaStats.description, 23 | """ 24 | min: (r: 0.306, g: 0.305, b: 0.306, a: 1.000) 25 | max: (r: 1.002, g: 1.002, b: 1.002, a: 1.000) 26 | avg: (r: 0.841, g: 0.838, b: 0.838, a: 1.000) 27 | """) 28 | } 29 | 30 | func testRenderInfo() { 31 | let result = testImage.debug.render() 32 | // Just test that those APIs don't crash. 33 | _ = result.image 34 | _ = testImage.debug.cgImage 35 | _ = testImage.pdfRepresentation 36 | _ = result.renderTask.pdfRepresentation 37 | _ = result.renderInfo.pdfRepresentation 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /Tests/EXRTests.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | import CoreImageExtensions 3 | import XCTest 4 | 5 | 6 | class EXRTests: XCTestCase { 7 | 8 | let context = CIContext() 9 | 10 | func testEXRDataCreation() { 11 | let testImage = CIImage.containing(values: CIVector(x: -2.0, y: 0.0, z: 3.0, w: 1.0))!.cropped(to: CGRect(x: 0, y: 0, width: 32, height: 16)) 12 | 13 | do { 14 | // Note: we need to render in a linear color space, otherwise gamma correction will be applied to the values 15 | let exrData = try self.context.exrRepresentation(of: testImage, format: .RGBAh, colorSpace: CGColorSpace(name: CGColorSpace.linearSRGB)) 16 | 17 | guard let loadedImage = CIImage(data: exrData) else { 18 | XCTFail("Failed to read EXR data back into image") 19 | return 20 | } 21 | 22 | let value = self.context.readFloat32PixelValue(from: loadedImage, at: .zero) 23 | XCTAssertEqual(value.r, -2.0, accuracy: 0.001) 24 | XCTAssertEqual(value.g, 0.0, accuracy: 0.001) 25 | XCTAssertEqual(value.b, 3.0, accuracy: 0.001) 26 | XCTAssertEqual(value.a, 1.0, accuracy: 0.001) 27 | } catch { 28 | XCTFail("Failed to create EXR data from image with error: \(error)") 29 | } 30 | } 31 | 32 | func testEXRLoading() { 33 | let testEXRImage = CIImage(named: "AllHalfValues.exr", in: Bundle.module)! 34 | 35 | // sample a random pixel value for testing 36 | let value1 = self.context.readFloat32PixelValue(from: testEXRImage, at: CGPoint(x: 10, y: 20)) 37 | XCTAssertEqual(value1.r, -3604.0) 38 | 39 | do { 40 | // try to render the image back into data and load again 41 | // Note: we need to render in a linear color space, otherwise gamma correction will be applied to the values 42 | let exrData = try self.context.exrRepresentation(of: testEXRImage, format: .RGBAh, colorSpace: CGColorSpace(name: CGColorSpace.linearSRGB)) 43 | guard let loadedImage = CIImage(data: exrData) else { 44 | XCTFail("Failed to read EXR data back into image") 45 | return 46 | } 47 | 48 | let value2 = self.context.readFloat32PixelValue(from: loadedImage, at: CGPoint(x: 10, y: 20)) 49 | XCTAssertEqual(value2.r, -3604.0, accuracy: 0.001) 50 | } catch { 51 | XCTFail("Failed to create EXR data from image with error: \(error)") 52 | } 53 | } 54 | 55 | func testEXRFileWriting() { 56 | let testImage = CIImage.containing(values: CIVector(x: -4.0, y: 2.0, z: 0.0, w: 1.0))!.cropped(to: CGRect(x: 0, y: 0, width: 16, height: 44)) 57 | 58 | let tempURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("test.exr") 59 | defer { try? FileManager.default.removeItem(at: tempURL) } 60 | 61 | do { 62 | // Note: we need to render in a linear color space, otherwise gamma correction will be applied to the values 63 | try self.context.writeEXRRepresentation(of: testImage, to: tempURL, format: .RGBAh, colorSpace: CGColorSpace(name: CGColorSpace.linearSRGB)) 64 | 65 | guard let loadedImage = CIImage(contentsOf: tempURL) else { 66 | XCTFail("Failed to read EXR data back into image") 67 | return 68 | } 69 | 70 | let value = self.context.readFloat32PixelValue(from: loadedImage, at: .zero) 71 | XCTAssertEqual(value.r, -4.0, accuracy: 0.001) 72 | XCTAssertEqual(value.g, 2.0, accuracy: 0.001) 73 | XCTAssertEqual(value.b, 0.0, accuracy: 0.001) 74 | XCTAssertEqual(value.a, 1.0, accuracy: 0.001) 75 | } catch { 76 | XCTFail("Failed to create EXR data from image with error: \(error)") 77 | } 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /Tests/Float16ValueAccessTests.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | import CoreImageExtensions 3 | import XCTest 4 | 5 | 6 | #if (os(iOS) || os(tvOS)) && !targetEnvironment(macCatalyst) 7 | 8 | @available(iOS 14, tvOS 14, *) 9 | @available(macOS, unavailable) 10 | @available(macCatalyst, unavailable) 11 | class Float16ValueAccessTests: XCTestCase { 12 | 13 | let context = CIContext() 14 | 15 | let testPixelImage = CIImage.containing(values: CIVector(x: -1.0, y: 0.0, z: 2.0, w: 1.0))!.cropped(to: .singlePixel) 16 | let test2x2Image = CIImage.containing(values: CIVector(x: 0.0, y: 0.5, z: 1.0, w: 1.0))!.cropped(to: .twoByTwo) 17 | 18 | func testReadPixels() { 19 | let values = self.context.readFloat16PixelValues(from: test2x2Image, in: .twoByTwo) 20 | XCTAssertEqual(values.count, 4) 21 | let pixelValue = Pixel(x: 0.0, y: 0.5, z: 1.0, w: 1.0) 22 | XCTAssertEqual(values, [pixelValue, pixelValue, pixelValue, pixelValue]) 23 | } 24 | 25 | func testReadPixel() { 26 | let value = self.context.readFloat16PixelValue(from: test2x2Image, at: CGPoint(x: 1, y: 0)) 27 | XCTAssertEqual(value, Pixel(x: 0.0, y: 0.5, z: 1.0, w: 1.0)) 28 | } 29 | 30 | func testExtendedRange() { 31 | let value = self.context.readFloat16PixelValue(from: testPixelImage, at: .zero) 32 | XCTAssertEqual(value, Pixel(x: -1.0, y: 0.0, z: 2.0, w: 1.0)) 33 | } 34 | 35 | } 36 | 37 | #endif 38 | -------------------------------------------------------------------------------- /Tests/Float32ValueAccessTests.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | import CoreImageExtensions 3 | import XCTest 4 | 5 | 6 | class Float32ValueAccessTests: XCTestCase { 7 | 8 | let context = CIContext() 9 | 10 | let testPixelImage = CIImage.containing(values: CIVector(x: -1.0, y: 0.0, z: 2.0, w: 1.0))!.cropped(to: .singlePixel) 11 | let test2x2Image = CIImage.containing(value: 0.5)!.cropped(to: .twoByTwo) 12 | 13 | func testReadPixels() { 14 | let values = self.context.readFloat32PixelValues(from: test2x2Image, in: .twoByTwo) 15 | XCTAssertEqual(values.count, 4) 16 | let pixelValue = Pixel(x: 0.5, y: 0.5, z: 0.5, w: 1.0) 17 | XCTAssertEqual(values, [pixelValue, pixelValue, pixelValue, pixelValue]) 18 | } 19 | 20 | func testReadPixel() { 21 | let value = self.context.readFloat32PixelValue(from: test2x2Image, at: CGPoint(x: 1, y: 0)) 22 | XCTAssertEqual(value, Pixel(x: 0.5, y: 0.5, z: 0.5, w: 1.0)) 23 | } 24 | 25 | func testExtendedRange() { 26 | let value = self.context.readFloat32PixelValue(from: testPixelImage, at: .zero) 27 | XCTAssertEqual(value, Pixel(x: -1.0, y: 0.0, z: 2.0, w: 1.0)) 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /Tests/Helpers.swift: -------------------------------------------------------------------------------- 1 | import CoreGraphics 2 | 3 | 4 | extension CGRect { 5 | 6 | static var singlePixel: CGRect { CGRect(x: 0, y: 0, width: 1, height: 1) } 7 | static var twoByTwo: CGRect { CGRect(x: 0, y: 0, width: 2, height: 2) } 8 | 9 | } 10 | -------------------------------------------------------------------------------- /Tests/ImageLookupTests.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | import CoreImageExtensions 3 | import XCTest 4 | 5 | 6 | class ImageLookupTests: XCTestCase { 7 | 8 | func testLoadingFromCatalog() { 9 | let bundle = Bundle.module 10 | let image = CIImage(named: "imageInCatalog", in: bundle) 11 | XCTAssertNotNil(image) 12 | } 13 | 14 | func testLoadingFromBundle() { 15 | let bundle = Bundle.module 16 | let image = CIImage(named: "imageInBundle", in: bundle) 17 | XCTAssertNotNil(image) 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /Tests/ImageTransformationTests.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | import CoreImageExtensions 3 | import XCTest 4 | 5 | 6 | class ImageTransformationTests: XCTestCase { 7 | 8 | /// Empty dummy image we can transform around to test effects on image extent. 9 | let testImage = CIImage(color: .clear).cropped(to: CGRect(x: 0, y: 0, width: 200, height: 100)) 10 | 11 | 12 | func testScaling() { 13 | let scaledImage = self.testImage.scaledBy(x: 0.5, y: 2.0) 14 | XCTAssertEqual(scaledImage.extent, CGRect(x: 0, y: 0, width: 100, height: 200)) 15 | } 16 | 17 | func testTranslation() { 18 | let translatedImage = self.testImage.translatedBy(dx: 42.0, dy: -321.0) 19 | XCTAssertEqual(translatedImage.extent, CGRect(origin: CGPoint(x: 42.0, y: -321.0), size: self.testImage.extent.size)) 20 | } 21 | 22 | func testMovingOrigin() { 23 | let newOrigin = CGPoint(x: 21.0, y: -42.0) 24 | let movedImage = self.testImage.moved(to: newOrigin) 25 | XCTAssertEqual(movedImage.extent, CGRect(origin: newOrigin, size: self.testImage.extent.size)) 26 | } 27 | 28 | func testCentering() { 29 | let recenteredImage = self.testImage.centered(at: .zero) 30 | XCTAssertEqual(recenteredImage.extent, CGRect(origin: CGPoint(x: -100.0, y: -50.0), size: self.testImage.extent.size)) 31 | } 32 | 33 | func testPadding() { 34 | let paddedImage = self.testImage.paddedBy(dx: 20, dy: 60) 35 | XCTAssertEqual(paddedImage.extent, CGRect(x: -20, y: -60, width: 240, height: 220)) 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /Tests/Resources/AllHalfValues.exr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalMasterpieces/CoreImageExtensions/1b18f548e7f956566f0f83b164830d42c1e942e4/Tests/Resources/AllHalfValues.exr -------------------------------------------------------------------------------- /Tests/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Tests/Resources/Assets.xcassets/imageInCatalog.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "imageInCatalog.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Tests/Resources/Assets.xcassets/imageInCatalog.imageset/imageInCatalog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalMasterpieces/CoreImageExtensions/1b18f548e7f956566f0f83b164830d42c1e942e4/Tests/Resources/Assets.xcassets/imageInCatalog.imageset/imageInCatalog.png -------------------------------------------------------------------------------- /Tests/Resources/imageInBundle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalMasterpieces/CoreImageExtensions/1b18f548e7f956566f0f83b164830d42c1e942e4/Tests/Resources/imageInBundle.png -------------------------------------------------------------------------------- /Tests/RuntimeMetalKernelTests.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | import CoreImageExtensions 3 | import XCTest 4 | 5 | 6 | @available(iOS 11.0, macCatalyst 13.1, macOS 10.13, tvOS 11.0, *) 7 | class RuntimeMetalKernelTests: XCTestCase { 8 | 9 | let metalKernelCode = """ 10 | #include 11 | using namespace metal; 12 | 13 | [[ stitchable ]] half4 general(coreimage::sampler_h src) { 14 | return src.sample(src.coord()); 15 | } 16 | 17 | [[ stitchable ]] half4 otherGeneral(coreimage::sampler_h src) { 18 | return src.sample(src.coord()); 19 | } 20 | 21 | [[ stitchable ]] half4 color(coreimage::sample_h src) { 22 | return src; 23 | } 24 | 25 | [[ stitchable ]] float2 warp(coreimage::destination dest) { 26 | return dest.coord(); 27 | } 28 | """ 29 | 30 | @available(iOS 15.0, macCatalyst 15.0, macOS 12.0, tvOS 15.0, *) 31 | func testRuntimeGeneralKernelCompilation() { 32 | XCTAssertNoThrow { 33 | let kernel = try CIKernel.kernel(withMetalString: self.metalKernelCode) 34 | XCTAssertEqual(kernel.name, "general") 35 | } 36 | XCTAssertNoThrow { 37 | let kernel = try CIKernel.kernel(withMetalString: self.metalKernelCode, kernelName: "otherGeneral") 38 | XCTAssertEqual(kernel.name, "otherGeneral") 39 | } 40 | XCTAssertThrowsError(try CIKernel.kernel(withMetalString: self.metalKernelCode, kernelName: "notFound")) 41 | } 42 | 43 | func testRuntimeGeneralKernelCompilationWithFallback() { 44 | let ciklKernelCode = """ 45 | kernel vec4 passThrough(sampler src) { 46 | return sample(src, samplerTransform(src, destCoord())); 47 | } 48 | """ 49 | XCTAssertNoThrow { 50 | let kernel = try CIKernel.kernel(withMetalString: self.metalKernelCode, fallbackCIKLString: ciklKernelCode) 51 | XCTAssertEqual(kernel.name, "general") 52 | } 53 | XCTAssertNoThrow { 54 | let kernel = try CIKernel.kernel(withMetalString: self.metalKernelCode, metalKernelName: "otherGeneral", fallbackCIKLString: ciklKernelCode) 55 | XCTAssertEqual(kernel.name, "otherGeneral") 56 | } 57 | XCTAssertThrowsError(try CIKernel.kernel(withMetalString: self.metalKernelCode, metalKernelName: "notFound", fallbackCIKLString: ciklKernelCode)) 58 | } 59 | 60 | @available(iOS 15.0, macCatalyst 15.0, macOS 12.0, tvOS 15.0, *) 61 | func testRuntimeColorKernelCompilation() { 62 | XCTAssertNoThrow { 63 | let kernel = try CIColorKernel.kernel(withMetalString: self.metalKernelCode) 64 | XCTAssertEqual(kernel.name, "color") 65 | } 66 | XCTAssertNoThrow { 67 | let kernel = try CIColorKernel.kernel(withMetalString: self.metalKernelCode, kernelName: "color") 68 | XCTAssertEqual(kernel.name, "color") 69 | } 70 | XCTAssertThrowsError(try CIColorKernel.kernel(withMetalString: self.metalKernelCode, kernelName: "notFound")) 71 | XCTAssertThrowsError(try CIColorKernel.kernel(withMetalString: self.metalKernelCode, kernelName: "general"), 72 | "Should not compile the general kernel as color kernel.") 73 | } 74 | 75 | func testRuntimeColorKernelCompilationWithFallback() { 76 | let ciklKernelCode = """ 77 | kernel vec4 color(__sample src) { 78 | return src.rgba; 79 | } 80 | """ 81 | XCTAssertNoThrow { 82 | let kernel = try CIColorKernel.kernel(withMetalString: self.metalKernelCode, fallbackCIKLString: ciklKernelCode) 83 | XCTAssertEqual(kernel.name, "color") 84 | } 85 | XCTAssertNoThrow { 86 | let kernel = try CIColorKernel.kernel(withMetalString: self.metalKernelCode, metalKernelName: "color", fallbackCIKLString: ciklKernelCode) 87 | XCTAssertEqual(kernel.name, "color") 88 | } 89 | XCTAssertThrowsError(try CIColorKernel.kernel(withMetalString: self.metalKernelCode, metalKernelName: "notFound", fallbackCIKLString: ciklKernelCode)) 90 | XCTAssertThrowsError(try CIColorKernel.kernel(withMetalString: self.metalKernelCode, metalKernelName: "general", fallbackCIKLString: ciklKernelCode), 91 | "Should not compile the general kernel as color kernel.") 92 | } 93 | 94 | @available(iOS 15.0, macCatalyst 15.0, macOS 12.0, tvOS 15.0, *) 95 | func testRuntimeWarpKernelCompilation() { 96 | XCTAssertNoThrow { 97 | let kernel = try CIWarpKernel.kernel(withMetalString: self.metalKernelCode) 98 | XCTAssertEqual(kernel.name, "warp") 99 | } 100 | XCTAssertNoThrow { 101 | let kernel = try CIWarpKernel.kernel(withMetalString: self.metalKernelCode, kernelName: "warp") 102 | XCTAssertEqual(kernel.name, "warp") 103 | } 104 | XCTAssertThrowsError(try CIWarpKernel.kernel(withMetalString: self.metalKernelCode, kernelName: "notFound")) 105 | XCTAssertThrowsError(try CIWarpKernel.kernel(withMetalString: self.metalKernelCode, kernelName: "general"), 106 | "Should not compile the general kernel as warp kernel.") 107 | } 108 | 109 | func testRuntimeWarpKernelCompilationWithFallback() { 110 | let ciklKernelCode = """ 111 | kernel vec2 warp() { 112 | return destCoord(); 113 | } 114 | """ 115 | XCTAssertNoThrow { 116 | let kernel = try CIWarpKernel.kernel(withMetalString: self.metalKernelCode, fallbackCIKLString: ciklKernelCode) 117 | XCTAssertEqual(kernel.name, "warp") 118 | } 119 | XCTAssertNoThrow { 120 | let kernel = try CIWarpKernel.kernel(withMetalString: self.metalKernelCode, metalKernelName: "warp", fallbackCIKLString: ciklKernelCode) 121 | XCTAssertEqual(kernel.name, "warp") 122 | } 123 | XCTAssertThrowsError(try CIWarpKernel.kernel(withMetalString: self.metalKernelCode, metalKernelName: "notFound", fallbackCIKLString: ciklKernelCode)) 124 | XCTAssertThrowsError(try CIWarpKernel.kernel(withMetalString: self.metalKernelCode, metalKernelName: "general", fallbackCIKLString: ciklKernelCode), 125 | "Should not compile the general kernel as warp kernel.") 126 | } 127 | 128 | } 129 | -------------------------------------------------------------------------------- /Tests/UInt8ValueAccessTests.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | import CoreImageExtensions 3 | import XCTest 4 | 5 | 6 | class UInt8ValueAccessTests: XCTestCase { 7 | 8 | let context = CIContext() 9 | 10 | let testPixelImage = CIImage.containing(values: CIVector(x: -1.0, y: 0.0, z: 2.0, w: 1.0))!.cropped(to: .singlePixel) 11 | let test2x2Image = CIImage.containing(values: CIVector(x: 0.0, y: 0.5, z: 1.0, w: 1.0))!.cropped(to: .twoByTwo) 12 | 13 | func testReadPixels() { 14 | let values = self.context.readUInt8PixelValues(from: test2x2Image, in: .twoByTwo) 15 | XCTAssertEqual(values.count, 4) 16 | let pixelValue = Pixel(x: 0, y: 128, z: 255, w: 255) 17 | XCTAssertEqual(values, [pixelValue, pixelValue, pixelValue, pixelValue]) 18 | } 19 | 20 | func testReadPixel() { 21 | let value = self.context.readUInt8PixelValue(from: test2x2Image, at: CGPoint(x: 0, y: 1)) 22 | XCTAssertEqual(value, Pixel(x: 0, y: 128, z: 255, w: 255)) 23 | } 24 | 25 | func testClamping() { 26 | let value = self.context.readUInt8PixelValue(from: testPixelImage, at: .zero) 27 | XCTAssertEqual(value, Pixel(x: 0, y: 0, z: 255, w: 255)) 28 | } 29 | 30 | } 31 | --------------------------------------------------------------------------------