color: ASC CDL SOP+sat math, pre-power clamp, Rec709 luma weights — 10 tests

This commit is contained in:
Forge Dev 2026-07-10 18:46:17 +00:00
parent 8bbcd7f94f
commit ac3cfe9815
3 changed files with 160 additions and 5 deletions

View file

@ -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<Float>
public var offset: SIMD3<Float>
public var power: SIMD3<Float>
public var saturation: Float
public init(slope: SIMD3<Float>, offset: SIMD3<Float>, power: SIMD3<Float>, 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<Float>(0.2126, 0.7152, 0.0722)
public func apply(_ rgb: SIMD3<Float>) -> SIMD3<Float> {
// SOP, clamped to >=0 before power for determinism (ASC pre-power clamp).
let sop = rgb * slope + offset
let zero = SIMD3<Float>.zero
let clamped = sop.replacing(with: zero, where: sop .< zero)
var out = SIMD3<Float>(
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<Float> { SIMD3(1, 1, 1) }
}

View file

@ -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<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)
}
}

View file

@ -1,5 +0,0 @@
import XCTest
final class ForgeColorPlaceholderTests: XCTestCase {
func testPlaceholder() { XCTAssertTrue(true) }
}