diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfb4dd5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +EDR_Wide_Gamut_Test_Patterns/* filter=lfs diff=lfs merge=lfs -text diff --git a/EDR_Wide_Gamut_Test_Patterns/TestPattern_10bit_BT.2020.heic b/EDR_Wide_Gamut_Test_Patterns/TestPattern_10bit_BT.2020.heic new file mode 100644 index 0000000..bfceab9 --- /dev/null +++ b/EDR_Wide_Gamut_Test_Patterns/TestPattern_10bit_BT.2020.heic @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ffcc0ec78e7c78927ef41dfe2bcfad68af1593d53c28fbafb1ee34355d9ad1b1 +size 131493 diff --git a/EDR_Wide_Gamut_Test_Patterns/TestPattern_10bit_BT.2100-HLG.heic b/EDR_Wide_Gamut_Test_Patterns/TestPattern_10bit_BT.2100-HLG.heic new file mode 100644 index 0000000..f8b7f46 --- /dev/null +++ b/EDR_Wide_Gamut_Test_Patterns/TestPattern_10bit_BT.2100-HLG.heic @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4d3a1292b92fa1c80f14678daed3ddea197c3b162fcce36dc62559a2f4c1c3f2 +size 129158 diff --git a/EDR_Wide_Gamut_Test_Patterns/TestPattern_10bit_BT.2100-PQ.heic b/EDR_Wide_Gamut_Test_Patterns/TestPattern_10bit_BT.2100-PQ.heic new file mode 100644 index 0000000..356d344 --- /dev/null +++ b/EDR_Wide_Gamut_Test_Patterns/TestPattern_10bit_BT.2100-PQ.heic @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07edd88bb72db7e9b227183c2ed05f565c7a40f3094dc402803fa0490c40d75f +size 116308 diff --git a/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_BT.2020.png b/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_BT.2020.png new file mode 100644 index 0000000..7c21a21 --- /dev/null +++ b/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_BT.2020.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db32326781f6ed194245f8505c7887dc6cc331ad5ad4a564b7a5c41acdf97942 +size 236072 diff --git a/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_BT.2100-HLG.png b/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_BT.2100-HLG.png new file mode 100644 index 0000000..0cc3b10 --- /dev/null +++ b/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_BT.2100-HLG.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fa08b2e9cd571856781404477984f6aa69d1117454aa8a4af8afab951fb07ef0 +size 245955 diff --git a/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_BT.2100-PQ.png b/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_BT.2100-PQ.png new file mode 100644 index 0000000..44ac227 --- /dev/null +++ b/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_BT.2100-PQ.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb8d6899c56eaa37140666a2e7752727544b6a3f4f8d5b58c0e09edca986437e +size 251702 diff --git a/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_float_BT.2020.tiff b/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_float_BT.2020.tiff new file mode 100644 index 0000000..b0546de --- /dev/null +++ b/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_float_BT.2020.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7dd203ceb00dfbde2e3fc03557c841b50fb87417ea5f8e7851eed3c938e181dd +size 13562298 diff --git a/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_float_BT.2100-HLG.tiff b/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_float_BT.2100-HLG.tiff new file mode 100644 index 0000000..f9242bc --- /dev/null +++ b/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_float_BT.2100-HLG.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d95ddda6e440eac4a7239d3efaabe455f000dd89e86198f0a8763b823928390e +size 13569062 diff --git a/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_float_BT.2100-PQ.tiff b/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_float_BT.2100-PQ.tiff new file mode 100644 index 0000000..0956eeb --- /dev/null +++ b/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_float_BT.2100-PQ.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1d0a3c55123236a0fc4dc4e06b6962daae9578555d85d5c9e95189679e5ab9d2 +size 13575206 diff --git a/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_float_extended-linear-sRGB.exr b/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_float_extended-linear-sRGB.exr new file mode 100644 index 0000000..ac1c908 --- /dev/null +++ b/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_float_extended-linear-sRGB.exr @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:37331e3287a3838d93160484f41db8cdf65eb2c3ad09be6eed90555d164cd3f5 +size 247891 diff --git a/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_float_extended-linear-sRGB.tiff b/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_float_extended-linear-sRGB.tiff new file mode 100644 index 0000000..361cae4 --- /dev/null +++ b/EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_float_extended-linear-sRGB.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e673af08e67c29096f6af7a10bf66d0dda7c207852b9dd6c375068a5919cb8f4 +size 13562290 diff --git a/EDR_Wide_Gamut_Test_Patterns/TestPattern_8bit_Display-P3.heic b/EDR_Wide_Gamut_Test_Patterns/TestPattern_8bit_Display-P3.heic new file mode 100644 index 0000000..40f261b --- /dev/null +++ b/EDR_Wide_Gamut_Test_Patterns/TestPattern_8bit_Display-P3.heic @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:722a4d80e1c7febcb0cd9baad13f0f550f3705663c581a2419a4b4d661dff386 +size 97933 diff --git a/EDR_Wide_Gamut_Test_Patterns/TestPattern_8bit_Display-P3.jpeg b/EDR_Wide_Gamut_Test_Patterns/TestPattern_8bit_Display-P3.jpeg new file mode 100644 index 0000000..8c4eff1 --- /dev/null +++ b/EDR_Wide_Gamut_Test_Patterns/TestPattern_8bit_Display-P3.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca26660893bf24e1358dbbe2d5e8ab51b198f4dd88f5f7c2b6c977e7c4f90841 +size 375001 diff --git a/EDR_Wide_Gamut_Test_Patterns/TestPattern_8bit_Display-P3.png b/EDR_Wide_Gamut_Test_Patterns/TestPattern_8bit_Display-P3.png new file mode 100644 index 0000000..3259d15 --- /dev/null +++ b/EDR_Wide_Gamut_Test_Patterns/TestPattern_8bit_Display-P3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b2e9325e2ea64cf3b4476158d169c8a135fb8280724249d5f340fe4fd8b41061 +size 146355 diff --git a/EDR_Wide_Gamut_Test_Patterns/TestPattern_8bit_sRGB.heic b/EDR_Wide_Gamut_Test_Patterns/TestPattern_8bit_sRGB.heic new file mode 100644 index 0000000..144cb88 --- /dev/null +++ b/EDR_Wide_Gamut_Test_Patterns/TestPattern_8bit_sRGB.heic @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e623635cd1a83a882d902ae0bc511937d950968cd9dc08c5e1b3f45a8ee5edd3 +size 95670 diff --git a/EDR_Wide_Gamut_Test_Patterns/TestPattern_8bit_sRGB.jpeg b/EDR_Wide_Gamut_Test_Patterns/TestPattern_8bit_sRGB.jpeg new file mode 100644 index 0000000..4b65d5a --- /dev/null +++ b/EDR_Wide_Gamut_Test_Patterns/TestPattern_8bit_sRGB.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97de9bd2b83de9e7e85f3779d690dffa34fd322e8c7ab934e462277fce366e79 +size 368584 diff --git a/EDR_Wide_Gamut_Test_Patterns/TestPattern_8bit_sRGB.png b/EDR_Wide_Gamut_Test_Patterns/TestPattern_8bit_sRGB.png new file mode 100644 index 0000000..868c56b --- /dev/null +++ b/EDR_Wide_Gamut_Test_Patterns/TestPattern_8bit_sRGB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:941968cacd8d0c3a7360c47660993f4b359988a61c70cef818adfd7efa5f5e25 +size 140555 diff --git a/EDR_Wide_Gamut_Test_Patterns/TestPattern_tone-mapped.png b/EDR_Wide_Gamut_Test_Patterns/TestPattern_tone-mapped.png new file mode 100644 index 0000000..7991961 --- /dev/null +++ b/EDR_Wide_Gamut_Test_Patterns/TestPattern_tone-mapped.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9ace0cc08116b9bbd5f9a7617aa6f57eed1f8e40bb223c93dedb5ee63104c0ff +size 154527 diff --git a/README.md b/README.md index 4e1a33b..31f10df 100644 --- a/README.md +++ b/README.md @@ -256,3 +256,35 @@ let kernel = try CIKernel.kernel(withMetalString: metalKernelCode, fallbackCIKLS > This way the compiler can check your sources at build-time, and initializing a CIKernel at runtime from pre-compiled sources is much faster. > 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). > But this should only be used as a last resort. + +## EDR & Wide Gamut Test Pattern +Most Apple devices can capture and display images with colors that are outside of the standard sRGB color gamut and range (_Standard Dynamic Range_, _SDR_). +The Display P3 color space is the de facto standard on Apple platforms now. +Some high-end devices and monitors also have XDR screens that can go even beyond Display P3, and new iPhones can record movies in HDR now. +All those systems (wide color gamut, high display brightness, extended color spaces) are subsumed by Apple under the term _EDR_ (_Extended Dynamic Range_). + +To ensure that all parts of our apps can properly process and display EDR media, we designed a test pattern image that +- displays a stripe of tiles with increasing pixel brightness value (up to the XDRs peak brightness of 1600 nits) +- and swatches of various colors in three common color gamuts (sRGB, Display P3, and BT.2020). + +![EDR & Wide Gamut Test Pattern](EDR_Wide_Gamut_Test_Patterns/TestPattern_tone-mapped.png) + +> **_Note:_** +> The image above is tone-mapped from HDR to SDR to demonstrate what it will roughly look like on a HDR-capable screen (just much dimmer). +> If you want to see the colors in their correct form (and see where you screen has to clip colors), check out the extended range [EXR](EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_float_extended-linear-sRGB.exr?raw=true) or [TIFF](EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_float_extended-linear-sRGB.tiff?raw=true) version of the image. +> The tone-mapped PNG version above was chosen so you can better see the intent of the pattern. + +The pattern image itself is generated with Core Image compositing techniques. +You can generate it as `CIImage` just like this: +```swift +let testPattern = CIImage.testPattern() +``` + +> **_Note:_** +> You should use this for testing purposes only! +> This is not meant to be shipped in production code since generating the pattern is slow and potentially error-prone (lots of force-unwraps in the code for convenience). +> If you need a fast-loading version of it, best use the pre-generated [EXR version](EDR_Wide_Gamut_Test_Patterns/TestPattern_16bit_float_extended-linear-sRGB.exr?raw=true). + +You can find many pre-generated test pattern images in various file formats, bit depths, and color spaces in the [EDR_Wide_Gamut_Test_Patterns](EDR_Wide_Gamut_Test_Patterns/) folder for download. +Those images can also be generated using the `TestPatternTests`. +The generated images are attached to the test runs and can be found when opening the test result in the Reports navigator. diff --git a/Sources/CIImage+TestPattern.swift b/Sources/CIImage+TestPattern.swift new file mode 100644 index 0000000..4363132 --- /dev/null +++ b/Sources/CIImage+TestPattern.swift @@ -0,0 +1,268 @@ +import CoreImage +import CoreImage.CIFilterBuiltins + + +/// Here we create an EDR brightness and wide gamut color test pattern image that can be used, for instance, +/// to verify colors are displayed correctly, filters conserve those color properties correctly, etc. +/// It also showcases how Core Image can be used for image compositing, though in production +/// a composition with this complexity is probably better done with Core Graphics since its too slow in CI. +/// ⚠️ For testing purposes only! This is not meant to be shipped in production code since generating +/// the pattern is slow and potentially error-prone (lots of force-unwraps in here for convenience). +@available(iOS 12.3, macCatalyst 13.1, macOS 10.14.3, tvOS 12.0, *) +extension CIImage { + + /// Create an EDR brightness and wide gamut color test pattern image that can be used, for instance, + /// to verify colors are displayed correctly, filters conserve those color properties correctly, etc. + /// + /// The pattern contains a scale of different brightness values up to a peak EDR brightness of 3.2, + /// which is the maximum brightness an XDR display from Apple can distinctly display. + /// + /// It also contains color swatches displaying different colors in tree different color spaces with + /// increasing color gamut: sRGB, Display P3, and BT.2020 (from HDR video). Most Apple displays support P3 now, + /// whereas only XDR displays and OLED screens can display the difference between P3 and BT.2020. + /// + /// You can also reduce the brightness of your display, which should reveal more levels of brightness and gamut. + /// + /// - Warning: ⚠️ For testing purposes only! This is not meant to be shipped in production code since generating + /// the pattern is slow and potentially error-prone (lots of force-unwraps in here for convenience). + /// + /// - Parameter label: An optional label text that will be added at the bottom left corner of the pattern. + /// This can be used, for instance, for adding information about file format and color space of the rendering. + /// - Returns: An EDR brightness and wide gamut color test pattern image. + public static func testPattern(label: String? = nil) -> CIImage { + // Note: Since Core Image's coordinate system has its origin in the lower left corner, we composite the pattern from the bottom up. + + // Start with an empty image and composite the pattern components on top. + var pattern = CIImage.empty() + + // Add wide gamut color swatches in a grid in the lower part of the pattern. + for (column, rowColors) in self.swatchColors.enumerated() { + // Note: We reverse here so the first color specified in the array is displayed on the top. + for (row, color) in rowColors.reversed().enumerated() { + var swatch = CIImage.colorSwatch(for: color) + swatch = swatch.moved(to: CGPoint(x: CGFloat(column) * (swatch.extent.width + self.margin), y: CGFloat(row) * (swatch.extent.height + self.margin))) + pattern = swatch.composited(over: pattern) + } + } + + // Add the brightness scale above the color swatches. + var brightnessScale = CIImage.brightnessScale(levels: self.brightnessScaleLevels) + brightnessScale = brightnessScale.moved(to: CGPoint(x: 0, y: pattern.extent.maxY + self.margin)) + pattern = brightnessScale.composited(over: pattern) + + // Add a nice title to the pattern. + var title = self.titleLabel() + title = title.moved(to: CGPoint(x: 0, y: pattern.extent.maxY + self.margin)) + pattern = title.composited(over: pattern) + + // Add the given text label at the bottom left corner of the image, if any. + if let labelText = label, !labelText.isEmpty { + var label = CIImage.text(labelText, fontName: self.labelFont, fontSize: 30, color: .white)! + label = label.moved(to: CGPoint(x: 0, y: -label.extent.height - self.margin)) + pattern = label.composited(over: pattern) + } + + // Put everything on a black background. + let background = CIImage(color: .black).cropped(to: pattern.extent.insetBy(dx: -self.margin, dy: -self.margin)) + return pattern.composited(over: background).moved(to: .zero) + } + + + // MARK: - Internal + + /// The size of the single color/brightness tiles. + private static var tileSize: CGSize { CGSize(width: 160, height: 160) } + /// The corner radius that is applied to color swatches and brightness scale. + private static var tileCornerRadius: Double { 10 } + /// The margin between elements in the pattern. + private static var margin: CGFloat { 50 } + /// The font to use for labels in the pattern. + private static var labelFont: String { ".AppleSystemUIFontCompactRounded-Semibold" } + /// Colors for which swatches should be rendered, ordered by columns (outer) and rows (inner arrays). + private static var swatchColors: [[CIColor]] { [[.red, .green, .blue], [.cyan, .magenta, .yellow]] } + /// The different color spaces in which colors should be rendered in the color swatches. + private static var swatchColorSpaces: [(label: String, colorSpace: CGColorSpace)] = [ + ("sRGB", .extendedLinearSRGBColorSpace!), + ("Display P3", .extendedLinearDisplayP3ColorSpace!), + ("BT.2020", .extendedLinearITUR2020ColorSpace!) + ] + /// The different brightness values that should be rendered in the brightness scale. + /// (The 3.2 is added manually because it's the peak brightness of Apple's XDR screens and should be included.) + private static var brightnessScaleLevels: [Double] { Array(stride(from: 0.0, through: 3.0, by: 0.5)) + [3.2] } + + + /// Creates a single tile (rectangle) filled with the given `color` in the given `colorSpace` with the given `size` + /// and the given `label` string displayed on top of it. + private static func colorTile(for color: CIColor, colorSpace: CGColorSpace, size: CGSize, label: String) -> CIImage { + // Match the color from the given color space to the working space + // so that all colors will be in the same base color space in the pattern. + var tile = CIImage(color: color).matchedToWorkingSpace(from: colorSpace)! + tile = tile.cropped(to: CGRect(origin: .zero, size: size)) + + // Put the label in the bottom left corner over the tile. + // Use a contrast color that is always readable, regardless of the tile's color. + let labelImage = CIImage.text(label, fontName: self.labelFont, fontSize: size.height / 8.0, color: color.contrastOverlayColor)! + return labelImage.moved(to: CGPoint(x: 5, y: 5)).composited(over: tile) + } + + /// Creates a swatch (line of colored tiles) for the given `color` for comparison. + /// Each tile will display the color in one of the `swatchColorSpaces`. + /// A label is added to the left next to the swatch displaying the color values. + private static func colorSwatch(for color: CIColor) -> CIImage { + // Generate a label to put next to the swatch. + let labelText = String(format: """ + R: %.2f + G: %.2f + B: %.2f + """, color.red, color.green, color.blue) + var label = CIImage.text(labelText, fontName: ".AppleSystemUIFontMonospaced-Semibold", fontSize: self.tileSize.height / 7.0, color: .white, padding: 18)! + + // Generate a color tile for each color space and place them next to each other. + var swatch = self.swatchColorSpaces.reduce(CIImage.empty()) { partialResult, entry in + var tile = CIImage.colorTile(for: color, colorSpace: entry.colorSpace, size: self.tileSize, label: entry.label) + tile = tile.translatedBy(dx: partialResult.extent.maxX.isInfinite ? 0 : partialResult.extent.maxX, dy: 0) + return tile.composited(over: partialResult) + } + // Also apply some round corners to the whole swatch. + swatch = swatch.withRoundedCorners(radius: self.tileCornerRadius)! + + // Adjust placement of label and swatch. + label = label.centered(at: CGPoint(x: label.extent.midX, y: swatch.extent.midY)) + swatch = swatch.moved(to: CGPoint(x: label.extent.maxX, y: 0)) + + return label.composited(over: swatch) + } + + /// Creates a single tile (rectangle) filled with white (or gray) in the given `brightness` value with the given `size`. + /// The tile will be labeled with the `brightness` value and the corresponding nits value + /// (assuming standard brightness `1.0` = 500 nits). + private static func brightnessTile(for brightness: Double, size: CGSize) -> CIImage { + // Create the tile containing the brightness in all color channels. + var tile = CIImage.containing(value: brightness)! + tile = tile.cropped(to: CGRect(origin: .zero, size: size)) + + // Put a label in the bottom left corner over the tile that displays the brightness value (also in nits). + let labelText = String(format: "%.2f (%d nits)", brightness, Int(brightness * 500)) + let labelColor = CIColor(extendedWhite: brightness)!.contrastOverlayColor + let label = CIImage.text(labelText, fontName: self.labelFont, fontSize: size.height / 8.0, color: labelColor)! + return label.moved(to: CGPoint(x: 5, y: 5)).composited(over: tile) + } + + /// Creates a "scale" (basically a long swatch) with tiles of different `levels` of brightness. + /// It also adds an annotation below the scale showing which part of the scale is SDR (standard dynamic range) + /// and which part is EDR (extended dynamic range). + private static func brightnessScale(levels: [Double]) -> CIImage { + // Create a long swatch containing the different brightness tiles. + var scale = levels.reduce(CIImage.empty()) { partialResult, brightness in + var tile = CIImage.brightnessTile(for: brightness, size: self.tileSize) + tile = tile.translatedBy(dx: partialResult.extent.maxX.isInfinite ? 0 : partialResult.extent.maxX, dy: 0) + return tile.composited(over: partialResult) + } + // Add some round corners. + scale = scale.withRoundedCorners(radius: self.tileCornerRadius)! + // Move the scale up so we can add the annotation below. + scale = scale.translatedBy(dx: 0, dy: 80) + + // Annotation properties. + let annotationColor = CIColor.white + let annotationLineWidth: CGFloat = 2.0 + + // Find the brightness that symbolizes "reference white" (i.e. brightness value 1.0). + let referenceWhiteIndex = levels.firstIndex(where: { $0 >= 1.0 }) ?? levels.indices.upperBound + // Find the place where the separator between SDR and EDR needs to go. + let sdrEndSeparatorX = CGFloat(referenceWhiteIndex + 1) * self.tileSize.width - annotationLineWidth / 2.0 + + // For drawing the "lines" we simply use a white image that we crop to narrow rectangles and place it under the scales. + let line = CIImage(color: annotationColor) + // One long line horizontal line below the scales. + scale = line.cropped(to: CGRect(x: 0, y: 30, width: scale.extent.width, height: annotationLineWidth)).composited(over: scale) + // Three small vertical "tick" lines marking the beginning and end of the swatch... + scale = line.cropped(to: CGRect(x: 0, y: 30, width: annotationLineWidth, height: 40)).composited(over: scale) + scale = line.cropped(to: CGRect(x: scale.extent.maxX - annotationLineWidth, y: 30, width: annotationLineWidth, height: 40)).composited(over: scale) + // ... and the border between SDR and EDR. + scale = line.cropped(to: CGRect(x: sdrEndSeparatorX, y: 30, width: annotationLineWidth, height: 40)).composited(over: scale) + + // Add an "SDR" label in the middle of the line below the SDR part of the scale. + var sdrLabel = CIImage.text("SDR", fontName: self.labelFont, fontSize: 35, color: annotationColor, padding: 10)! + sdrLabel = sdrLabel.moved(to: CGPoint(x: (sdrEndSeparatorX - sdrLabel.extent.width) / 2.0, y: 0)) + // Remove the part below the label from the annotation line by multiplying its color with black (zero) before adding the label on top. + scale = CIImage(color: .black).cropped(to: sdrLabel.extent).composited(over: scale, using: .multiply)! + scale = sdrLabel.composited(over: scale) + + // Add an "EDR" label in the middle of the line below the EDR part of the scale. + var hdrLabel = CIImage.text("EDR", fontName: self.labelFont, fontSize: 35, color: annotationColor, padding: 10)! + hdrLabel = hdrLabel.moved(to: CGPoint(x: sdrEndSeparatorX + (scale.extent.width - sdrEndSeparatorX - hdrLabel.extent.width) / 2.0, y: 0)) + // Remove the part below the label from the annotation line by multiplying its color with black (zero) before adding the label on top. + scale = CIImage(color: .black).cropped(to: hdrLabel.extent).composited(over: scale, using: .multiply)! + scale = hdrLabel.composited(over: scale) + + return scale + } + + /// Creates a title label for the pattern that reads "EDR & Wide Gamut Test Pattern" with some fancy gradients. + private static func titleLabel() -> CIImage { + // Create a title label image. The 97 point font size will let the label have roughly the same width as the brightness scale. + var label = CIImage.text("EDR & Wide Gamut Test Pattern", fontName: self.labelFont, fontSize: 97, color: .white)! + + // Add a gradient from bright EDR white to normal white over the "EDR" part of the label. + let edrLabelRect = CGRect(x: 0, y: 0, width: 169, height: 116) + let edrGradient = CIFilter(name: "CISmoothLinearGradient", parameters: [ + "inputPoint0": CIVector(x: edrLabelRect.minX, y: edrLabelRect.midY), + "inputPoint1": CIVector(x: edrLabelRect.maxX, y: edrLabelRect.midY), + "inputColor0": CIColor(extendedWhite: 3.2)!, + "inputColor1": CIColor(white: 1.0)! + ])!.outputImage!.cropped(to: edrLabelRect) + label = edrGradient.composited(over: label, using: .sourceAtop)! + + // Add a "rainbow" color gradient over the "Wide Gamut" part of the Label. + let wideGamutRect = CGRect(x: 248, y: 0, width: 523, height: 116) + let wideGamutGradient = self.hueGradient(from: wideGamutRect.minX, to: wideGamutRect.maxX) + .cropped(to: wideGamutRect) + .matchedToWorkingSpace(from: .extendedLinearITUR2020ColorSpace!)! + label = wideGamutGradient.composited(over: label, using: .sourceAtop)! + + return label + } + + /// Generates an image containing a horizontal "rainbow" gradient containing all color hues. + /// The hues are mapped to the x-axis between `startX` and `endX`. + /// + /// - Warning: ⚠️ This uses a simple color kernel written in Metal and a fallback version + /// for older OSes, written in the very much deprecated + /// Core Image Kernel Language that are compiled at runtime. + /// This is mainly for convenience reasons, but also because custom + /// Metal kernels can't be compiled at build-time in a Swift package yet. + /// If you want to use something similar in production, you should implement + /// it using a Metal kernel that is compiled (and validated) together with the + /// rest of the code. + private static func hueGradient(from startX: CGFloat, to endX: CGFloat) -> CIImage { + let metalKernelCode = """ + #include + using namespace metal; + + [[ stitchable ]] half4 hueGradient(half startX, half endX, coreimage::destination dest) { + // Map the x-axis between the points to the hue value in HSV color space. + const half hue = (dest.coord().x - startX) / (endX - startX); + const half3 hsv = half3(clamp(hue, 0.0h, 1.0h), 1.0h, 1.0h); + // Convert HSV back to RGB (taken from https://stackoverflow.com/a/17897228/541016). + const half4 K = half4(1.0h, 2.0h / 3.0h, 1.0h / 3.0h, 3.0h); + const half3 p = abs(fract(hsv.xxx + K.xyz) * 6.0h - K.www); + return half4(hsv.z * mix(K.xxx, clamp(p - K.xxx, 0.0h, 1.0h), hsv.y), 1.0h); + } + """ + let ciklKernelCode = """ + kernel vec4 hueGradient(float startX, float endX) { + // Map the x-axis between the points to the hue value in HSV color space. + float hue = (destCoord().x - startX) / (endX - startX); + vec3 hsv = vec3(clamp(hue, 0.0, 1.0), 1.0, 1.0); + // Convert HSV back to RGB (taken from https://stackoverflow.com/a/17897228/541016). + vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); + vec3 p = abs(fract(hsv.xxx + K.xyz) * 6.0 - K.www); + return vec4(hsv.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), hsv.y), 1.0); + } + """ + let kernel = try! CIColorKernel.kernel(withMetalString: metalKernelCode, fallbackCIKLString: ciklKernelCode) + return kernel.apply(extent: .infinite, arguments: [startX, endX])! + } + +} diff --git a/Tests/TestPatternTests.swift b/Tests/TestPatternTests.swift new file mode 100644 index 0000000..fdc2cdf --- /dev/null +++ b/Tests/TestPatternTests.swift @@ -0,0 +1,144 @@ +import CoreImage +import CoreImageExtensions +import UniformTypeIdentifiers +import XCTest + + +/// These tests can be used to generate EDR & wide gamut test pattern images in various formats. +/// The generated images are attached to the test runs and can be found when opening the test result +/// in the Reports navigator. +@available(iOS 14.0, macOS 11.0, macCatalyst 14.0, tvOS 14.0, *) +class TestPatternTests: XCTestCase { + + let context = CIContext() + + /// Helper type for assigning some human-readable labels to color spaces. + private typealias ColorSpace = (cgSpace: CGColorSpace, label: String) + + // Some common color spaces that we want to generate patterns in. + private let sRGBColorSpace = ColorSpace(.sRGBColorSpace!, "sRGB") + private let extendedLinearSRGBColorSpace = ColorSpace(.extendedLinearSRGBColorSpace!, "extended linear sRGB") + private let displayP3ColorSpace = ColorSpace(.displayP3ColorSpace!, "Display P3") + private let itur2020ColorSpace = ColorSpace(.itur2020ColorSpace!, "BT.2020") + private let itur2100HLGColorSpace = ColorSpace(.itur2100HLGColorSpace!, "BT.2100 HLG") + private let itur2100PQColorSpace = ColorSpace(.itur2100PQColorSpace!, "BT.2100 PQ") + + /// Color spaces that are suitable for image formats with low bit-depth (i.e. 8-bit). + private lazy var lowBitColorSpaces: [ColorSpace] = [sRGBColorSpace, displayP3ColorSpace] + /// Color spaces that require image formats with higher bit depth to not cause quantization artifacts + /// between consecutive color values. + private lazy var highBitColorSpaces: [ColorSpace] = [itur2020ColorSpace, itur2100HLGColorSpace, itur2100PQColorSpace] + + + /// Generates a test pattern image. The parameters are used to compose the label that is added at the bottom of the pattern image. + private func testPattern(for fileType: UTType, bitDepth: Int, isFloat: Bool, colorSpace: ColorSpace) -> CIImage { + let label = "\(fileType.preferredFilenameExtension!.uppercased()), \(bitDepth)-bit, \(isFloat ? "float, " : "")\(colorSpace.label)" + return CIImage.testPattern(label: label) + } + + /// Attaches the given image `data` of the given `type` to the test case so it can be retrieved from the test report after the run. + /// The other parameters are used for naming the file in accordance with the image properties. + private func attach(_ data: Data, type: UTType, bitDepth: Int, isFloat: Bool, colorSpace: ColorSpace) { + let attachment = XCTAttachment(data: data, uniformTypeIdentifier: type.identifier) + attachment.lifetime = .keepAlways + let colorSpaceFileName = colorSpace.label.replacingOccurrences(of: " ", with: "-") + attachment.name = "TestPattern_\(bitDepth)bit_\(isFloat ? "float_" : "")\(colorSpaceFileName).\(type.preferredFilenameExtension!)" + self.add(attachment) + } + + + /// Generates an EDR & wide gamut test pattern image in EXR file format. + /// Since EDR can store values as-is, we only generate one 16-bit float image in extended linear sRGB color space, + /// which are the reference properties when composing the test pattern. + func testEXRPatternGeneration() throws { + let testPattern = self.testPattern(for: .exr, bitDepth: 16, isFloat: true, colorSpace: extendedLinearSRGBColorSpace) + let data = try self.context.exrRepresentation(of: testPattern, format: .RGBAh, colorSpace: extendedLinearSRGBColorSpace.cgSpace) + self.attach(data, type: .exr, bitDepth: 16, isFloat: true, colorSpace: extendedLinearSRGBColorSpace) + + } + + /// Generates EDR & wide gamut test pattern images in TIFF file format. + /// Since TIFF can store 16-bit floating-point values, we generate an image in extended linear sRGB color space, + /// which are the reference properties when composing the test pattern. + /// We also generate images in the HDR color spaces for reference. + func testTIFFPatternGeneration() { + for colorSpace in highBitColorSpaces + [extendedLinearSRGBColorSpace] { + let testPattern = self.testPattern(for: .tiff, bitDepth: 16, isFloat: true, colorSpace: colorSpace) + let data = self.context.tiffRepresentation(of: testPattern, format: .RGBAh, colorSpace: colorSpace.cgSpace)! + self.attach(data, type: .tiff, bitDepth: 16, isFloat: true, colorSpace: colorSpace) + } + } + + /// Generates EDR & wide gamut test pattern images in PNG file format. + /// PNG supports 8- and 16-bit color depths, so we generate patterns in the color spaces fitting those bit depths. + /// However, PNG does not support floating-point values, so we don't need to generate an image in extended color space. + func testPNGPatternGeneration() { + for colorSpace in lowBitColorSpaces { + let testPattern = self.testPattern(for: .png, bitDepth: 8, isFloat: false, colorSpace: colorSpace) + let data = self.context.pngRepresentation(of: testPattern, format: .RGBA8, colorSpace: colorSpace.cgSpace)! + self.attach(data, type: .png, bitDepth: 8, isFloat: false, colorSpace: colorSpace) + } + + for colorSpace in highBitColorSpaces { + let testPattern = self.testPattern(for: .png, bitDepth: 16, isFloat: false, colorSpace: colorSpace) + let data = self.context.pngRepresentation(of: testPattern, format: .RGBAh, colorSpace: colorSpace.cgSpace)! + self.attach(data, type: .png, bitDepth: 16, isFloat: false, colorSpace: colorSpace) + } + } + + /// Generates EDR & wide gamut test pattern images in JPEG file format. + /// JPEG only supports 8-bit color depth, so we only generate images in color spaces that are fitting for 8-bit. + func testJPEGPatternGeneration() { + for colorSpace in lowBitColorSpaces { + let testPattern = self.testPattern(for: .jpeg, bitDepth: 8, isFloat: false, colorSpace: colorSpace) + let data = self.context.jpegRepresentation(of: testPattern, colorSpace: colorSpace.cgSpace, options: [.quality: 1.0])! + self.attach(data, type: .jpeg, bitDepth: 8, isFloat: false, colorSpace: colorSpace) + } + } + + /// Generates EDR & wide gamut test pattern images in HEIC file format. + /// HEIC supports 8- and 10-bit color depths, so we generate patterns in the color spaces fitting those bit depths. + /// However, HEIC does not support floating-point values, so we don't need to generate an image in extended color space. + func testHEICPatternGeneration() throws { + for colorSpace in lowBitColorSpaces { + let testPattern = self.testPattern(for: .heic, bitDepth: 8, isFloat: false, colorSpace: colorSpace) + let data = self.context.heifRepresentation(of: testPattern, format: .RGBA8, colorSpace: colorSpace.cgSpace, options: [.quality: 1.0])! + self.attach(data, type: .heic, bitDepth: 8, isFloat: false, colorSpace: colorSpace) + } + + if #available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) { + for colorSpace in highBitColorSpaces { + let testPattern = self.testPattern(for: .heic, bitDepth: 10, isFloat: false, colorSpace: colorSpace) + let data = try! self.context.heif10Representation(of: testPattern, colorSpace: colorSpace.cgSpace, options: [.quality: 1.0]) + self.attach(data, type: .heic, bitDepth: 10, isFloat: false, colorSpace: colorSpace) + } + } + } + + /// Generates EDR & wide gamut test pattern images in PNG file format that is tone-mapped from BT.2100 PQ (HDR) to sRGB + /// to demonstrate what it might roughly like on EDR-capable screens (just much dimmer). + func testToneMappedPatternGeneration() { + let testPattern = CIImage.testPattern(label: "BT.2100 PQ (HDR) tone-mapped to sRGB") + // Create a pattern image that contains HDR data. + let hdrData = self.context.pngRepresentation(of: testPattern, format: .RGBAh, colorSpace: .itur2100PQColorSpace!)! + // Load that data again into a `CIImage` and let CI perform tone-mapping to SDR. + let toneMappedImage = CIImage(data: hdrData, options: [CIImageOption.toneMapHDRtoSDR: true])! + // Render the tone-mapped SDR image in sRGB and save as attachment. + let data = self.context.pngRepresentation(of: toneMappedImage, format: .RGBA8, colorSpace: .sRGBColorSpace!)! + let attachment = XCTAttachment(data: data, uniformTypeIdentifier: UTType.png.identifier) + attachment.lifetime = .keepAlways + attachment.name = "TestPattern_tone-mapped.png" + self.add(attachment) + } + +} + + +@available(iOS 14.0, macOS 11.0, macCatalyst 14.0, tvOS 14.0, *) +private extension UTType { + static var exr: Self { UTType("com.ilm.openexr-image")! } +} + +private extension CIImageRepresentationOption { + static var quality: Self { CIImageRepresentationOption(rawValue: kCGImageDestinationLossyCompressionQuality as String) } +}