diff --git a/Sources/ForgeColor/CDL.swift b/Sources/ForgeColor/CDL.swift new file mode 100644 index 0000000..a370653 --- /dev/null +++ b/Sources/ForgeColor/CDL.swift @@ -0,0 +1,46 @@ +import Foundation + +/// ASC CDL primary correction: out = clamp0(in * slope + offset)^power, then saturation. +/// Saturation uses Rec709 luma weights per ASC CDL 1.2. +public struct CDL: Equatable, Sendable { + public var slope: SIMD3 + public var offset: SIMD3 + public var power: SIMD3 + public var saturation: Float + + public init(slope: SIMD3, offset: SIMD3, power: SIMD3, saturation: Float) { + self.slope = slope + self.offset = offset + self.power = power + self.saturation = saturation + } + + public static let identity = CDL(slope: .one, offset: .zero, power: .one, saturation: 1) + + public var isIdentity: Bool { self == .identity } + + /// Rec709 luma weights (ASC CDL saturation spec). + static let lumaWeights = SIMD3(0.2126, 0.7152, 0.0722) + + public func apply(_ rgb: SIMD3) -> SIMD3 { + // SOP, clamped to >=0 before power for determinism (ASC pre-power clamp). + let sop = rgb * slope + offset + let zero = SIMD3.zero + let clamped = sop.replacing(with: zero, where: sop .< zero) + var out = SIMD3( + pow(clamped.x, power.x), + pow(clamped.y, power.y), + pow(clamped.z, power.z)) + + // Saturation about Rec709 luma. + if saturation != 1 { + let luma = (out * Self.lumaWeights).sum() + out = SIMD3(repeating: luma) + (out - SIMD3(repeating: luma)) * saturation + } + return out + } +} + +extension SIMD3 where Scalar == Float { + public static var one: SIMD3 { SIMD3(1, 1, 1) } +} diff --git a/Tests/ForgeColorTests/CDLTests.swift b/Tests/ForgeColorTests/CDLTests.swift new file mode 100644 index 0000000..0c72471 --- /dev/null +++ b/Tests/ForgeColorTests/CDLTests.swift @@ -0,0 +1,114 @@ +import XCTest +@testable import ForgeColor + +final class CDLTests: XCTestCase { + + // Identity CDL passes pixels through untouched. + func testIdentityPassthrough() { + let cdl = CDL.identity + let px = SIMD3(0.18, 0.5, 0.9) + let out = cdl.apply(px) + XCTAssertEqual(out.x, 0.18, accuracy: 1e-6) + XCTAssertEqual(out.y, 0.5, accuracy: 1e-6) + XCTAssertEqual(out.z, 0.9, accuracy: 1e-6) + } + + // Hand-computed SOP vector. + // in=0.5, slope=2, offset=0.1, power=1 -> 0.5*2+0.1 = 1.1 + func testSlopeOffset() { + let cdl = CDL( + slope: SIMD3(2, 2, 2), + offset: SIMD3(0.1, 0.1, 0.1), + power: SIMD3(1, 1, 1), + saturation: 1) + let out = cdl.apply(SIMD3(0.5, 0.5, 0.5)) + XCTAssertEqual(out.x, 1.1, accuracy: 1e-6) + XCTAssertEqual(out.y, 1.1, accuracy: 1e-6) + XCTAssertEqual(out.z, 1.1, accuracy: 1e-6) + } + + // Power applies after slope+offset: (0.25*1+0)^0.5 = 0.5 + func testPower() { + let cdl = CDL( + slope: .one, offset: .zero, + power: SIMD3(0.5, 0.5, 0.5), + saturation: 1) + let out = cdl.apply(SIMD3(0.25, 0.25, 0.25)) + XCTAssertEqual(out.x, 0.5, accuracy: 1e-6) + } + + // Per-channel independence. + func testPerChannel() { + let cdl = CDL( + slope: SIMD3(1, 2, 0.5), + offset: SIMD3(0, 0.1, -0.05), + power: SIMD3(1, 1, 2), + saturation: 1) + let out = cdl.apply(SIMD3(0.4, 0.4, 0.4)) + XCTAssertEqual(out.x, 0.4, accuracy: 1e-6) // 0.4*1+0 + XCTAssertEqual(out.y, 0.9, accuracy: 1e-6) // 0.4*2+0.1 + XCTAssertEqual(out.z, pow(0.4 * 0.5 - 0.05, 2), accuracy: 1e-6) // (0.15)^2 + } + + // ASC: value after SOP clamped to >= 0 before power (avoid NaN on negatives). + func testNegativePrePowerClamp() { + let cdl = CDL( + slope: .one, + offset: SIMD3(-1, -1, -1), + power: SIMD3(0.5, 0.5, 0.5), + saturation: 1) + let out = cdl.apply(SIMD3(0.5, 0.5, 0.5)) // 0.5-1 = -0.5 -> clamp 0 -> 0^0.5 = 0 + XCTAssertEqual(out.x, 0, accuracy: 1e-6) + XCTAssertFalse(out.x.isNaN) + } + + // Power=1 keeps negatives (linear passthrough, no clamp needed) per common practice: + // clamp only applies to the pow() branch. Spec-conformant tools clamp pre-power always; + // we clamp always for determinism. + func testClampAlwaysPrePower() { + let cdl = CDL(slope: .one, offset: SIMD3(-1, -1, -1), power: .one, saturation: 1) + let out = cdl.apply(SIMD3(0.5, 0.5, 0.5)) + XCTAssertEqual(out.x, 0, accuracy: 1e-6) + } + + // Saturation 0 -> pure luma gray using Rec709 weights (0.2126, 0.7152, 0.0722). + func testSaturationZeroGivesLumaGray() { + let cdl = CDL(slope: .one, offset: .zero, power: .one, saturation: 0) + let px = SIMD3(1, 0, 0) + let out = cdl.apply(px) + let luma: Float = 0.2126 + XCTAssertEqual(out.x, luma, accuracy: 1e-6) + XCTAssertEqual(out.y, luma, accuracy: 1e-6) + XCTAssertEqual(out.z, luma, accuracy: 1e-6) + } + + // Saturation 1 is no-op on chroma. + func testSaturationOneNoOp() { + let cdl = CDL(slope: .one, offset: .zero, power: .one, saturation: 1) + let px = SIMD3(0.7, 0.2, 0.4) + let out = cdl.apply(px) + XCTAssertEqual(out.x, 0.7, accuracy: 1e-6) + XCTAssertEqual(out.y, 0.2, accuracy: 1e-6) + XCTAssertEqual(out.z, 0.4, accuracy: 1e-6) + } + + // Saturation 2 doubles distance from luma: out = luma + 2*(in - luma) + func testSaturationBoost() { + let cdl = CDL(slope: .one, offset: .zero, power: .one, saturation: 2) + let px = SIMD3(0.6, 0.3, 0.3) + let luma: Float = 0.2126 * 0.6 + 0.7152 * 0.3 + 0.0722 * 0.3 + let out = cdl.apply(px) + XCTAssertEqual(out.x, luma + 2 * (0.6 - luma), accuracy: 1e-5) + XCTAssertEqual(out.y, luma + 2 * (0.3 - luma), accuracy: 1e-5) + } + + // Identity constant is actually identity params. + func testIdentityConstants() { + XCTAssertEqual(CDL.identity.slope, SIMD3(1, 1, 1)) + XCTAssertEqual(CDL.identity.offset, SIMD3(0, 0, 0)) + XCTAssertEqual(CDL.identity.power, SIMD3(1, 1, 1)) + XCTAssertEqual(CDL.identity.saturation, 1) + XCTAssertTrue(CDL.identity.isIdentity) + XCTAssertFalse(CDL(slope: SIMD3(1.1, 1, 1), offset: .zero, power: .one, saturation: 1).isIdentity) + } +} diff --git a/Tests/ForgeColorTests/PlaceholderTests.swift b/Tests/ForgeColorTests/PlaceholderTests.swift deleted file mode 100644 index 160225e..0000000 --- a/Tests/ForgeColorTests/PlaceholderTests.swift +++ /dev/null @@ -1,5 +0,0 @@ -import XCTest - -final class ForgeColorPlaceholderTests: XCTestCase { - func testPlaceholder() { XCTAssertTrue(true) } -}