color: ASC CDL SOP+sat math, pre-power clamp, Rec709 luma weights — 10 tests
This commit is contained in:
parent
8bbcd7f94f
commit
ac3cfe9815
3 changed files with 160 additions and 5 deletions
46
Sources/ForgeColor/CDL.swift
Normal file
46
Sources/ForgeColor/CDL.swift
Normal 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) }
|
||||
}
|
||||
114
Tests/ForgeColorTests/CDLTests.swift
Normal file
114
Tests/ForgeColorTests/CDLTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import XCTest
|
||||
|
||||
final class ForgeColorPlaceholderTests: XCTestCase {
|
||||
func testPlaceholder() { XCTAssertTrue(true) }
|
||||
}
|
||||
Loading…
Reference in a new issue