rainbow-dragon/Tests/ForgeColorTests/CDLTests.swift

114 lines
4.5 KiB
Swift

import XCTest
@testable import ForgeColor
final class CDLTests: XCTestCase {
// Identity CDL passes pixels through untouched.
func testIdentityPassthrough() {
let cdl = CDL.identity
let px = SIMD3<Float>(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<Float>(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<Float>(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<Float>(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<Float>(1, 1, 1))
XCTAssertEqual(CDL.identity.offset, SIMD3<Float>(0, 0, 0))
XCTAssertEqual(CDL.identity.power, SIMD3<Float>(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)
}
}