grade: node stack (input/cdl/sat/curves/lut3d/output), bypass, ordering semantics — 10 tests
This commit is contained in:
parent
e3318b073d
commit
195050205a
4 changed files with 204 additions and 6 deletions
|
|
@ -20,7 +20,7 @@ public struct CDL: Equatable, Sendable {
|
|||
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 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).
|
||||
|
|
|
|||
96
Sources/ForgeGrade/GradeStack.swift
Normal file
96
Sources/ForgeGrade/GradeStack.swift
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import Foundation
|
||||
import ForgeColor
|
||||
|
||||
/// Named input transform (camera log -> linear Rec709 working space).
|
||||
public enum InputTransform: String, Sendable, Codable, CaseIterable {
|
||||
case arriLogC4AWG4
|
||||
case redLog3G10RWG
|
||||
case sonySLog3SGamut3Cine
|
||||
case none
|
||||
|
||||
public var colorSpace: ColorSpace {
|
||||
switch self {
|
||||
case .arriLogC4AWG4: return .arriLogC4AWG4
|
||||
case .redLog3G10RWG: return .redLog3G10RWG
|
||||
case .sonySLog3SGamut3Cine: return .sonySLog3SGamut3Cine
|
||||
case .none: return .rec709
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Named output transform (linear working space -> display).
|
||||
public enum OutputTransform: String, Sendable, Codable, CaseIterable {
|
||||
case rec709Display // 2.4 gamma
|
||||
case none
|
||||
|
||||
public func apply(_ rgb: SIMD3<Float>) -> SIMD3<Float> {
|
||||
switch self {
|
||||
case .none:
|
||||
return rgb
|
||||
case .rec709Display:
|
||||
let g: Float = 1 / 2.4
|
||||
return SIMD3(
|
||||
pow(max(rgb.x, 0), g),
|
||||
pow(max(rgb.y, 0), g),
|
||||
pow(max(rgb.z, 0), g))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One node in a grade stack.
|
||||
public struct GradeNode: Identifiable, Sendable {
|
||||
public enum Kind: Sendable {
|
||||
case inputTransform(InputTransform)
|
||||
case cdl(CDL)
|
||||
case saturation(Float)
|
||||
case curves(CurveSet)
|
||||
case lut3d(Lut3D)
|
||||
case outputTransform(OutputTransform)
|
||||
}
|
||||
|
||||
public let id: UUID
|
||||
public var kind: Kind
|
||||
public var isEnabled: Bool
|
||||
|
||||
public init(id: UUID = UUID(), kind: Kind, isEnabled: Bool = true) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.isEnabled = isEnabled
|
||||
}
|
||||
|
||||
public func apply(_ rgb: SIMD3<Float>) -> SIMD3<Float> {
|
||||
guard isEnabled else { return rgb }
|
||||
switch kind {
|
||||
case .inputTransform(let t):
|
||||
return t.colorSpace.toLinearRec709(rgb)
|
||||
case .cdl(let cdl):
|
||||
return cdl.apply(rgb)
|
||||
case .saturation(let sat):
|
||||
let luma = (rgb * CDL.lumaWeights).sum()
|
||||
return SIMD3(repeating: luma) + (rgb - SIMD3(repeating: luma)) * sat
|
||||
case .curves(let set):
|
||||
return set.apply(rgb)
|
||||
case .lut3d(let lut):
|
||||
return lut.sample(rgb)
|
||||
case .outputTransform(let t):
|
||||
return t.apply(rgb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ordered node stack. Evaluation composes nodes top to bottom.
|
||||
public struct GradeStack: Sendable {
|
||||
public var nodes: [GradeNode]
|
||||
|
||||
public init(nodes: [GradeNode] = []) {
|
||||
self.nodes = nodes
|
||||
}
|
||||
|
||||
public func evaluate(_ rgb: SIMD3<Float>) -> SIMD3<Float> {
|
||||
var v = rgb
|
||||
for node in nodes {
|
||||
v = node.apply(v)
|
||||
}
|
||||
return v
|
||||
}
|
||||
}
|
||||
107
Tests/ForgeGradeTests/GradeStackTests.swift
Normal file
107
Tests/ForgeGradeTests/GradeStackTests.swift
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import XCTest
|
||||
import ForgeColor
|
||||
@testable import ForgeGrade
|
||||
|
||||
final class GradeStackTests: XCTestCase {
|
||||
|
||||
// Empty stack = identity.
|
||||
func testEmptyStackIdentity() {
|
||||
let stack = GradeStack(nodes: [])
|
||||
let px = SIMD3<Float>(0.3, 0.5, 0.7)
|
||||
XCTAssertEqual(stack.evaluate(px), px)
|
||||
}
|
||||
|
||||
// Single CDL node applies CDL math.
|
||||
func testSingleCDLNode() {
|
||||
let cdl = CDL(slope: SIMD3(2, 2, 2), offset: .zero, power: .one, saturation: 1)
|
||||
let stack = GradeStack(nodes: [GradeNode(kind: .cdl(cdl))])
|
||||
let out = stack.evaluate(SIMD3(0.25, 0.25, 0.25))
|
||||
XCTAssertEqual(out.x, 0.5, accuracy: 1e-6)
|
||||
}
|
||||
|
||||
// Ordering matters: CDL(x2) then LUT(half) != LUT(half) then CDL(x2) on clamping LUT.
|
||||
func testOrderingMatters() {
|
||||
let double = CDL(slope: SIMD3(2, 2, 2), offset: .zero, power: .one, saturation: 1)
|
||||
// LUT clamps at 1: input 0.8 doubled -> 1.6 -> LUT sample clamps to 1 -> 0.5
|
||||
let halfLut = Lut3D.build(size: 17) { $0 * 0.5 }
|
||||
|
||||
let cdlFirst = GradeStack(nodes: [
|
||||
GradeNode(kind: .cdl(double)),
|
||||
GradeNode(kind: .lut3d(halfLut)),
|
||||
])
|
||||
let lutFirst = GradeStack(nodes: [
|
||||
GradeNode(kind: .lut3d(halfLut)),
|
||||
GradeNode(kind: .cdl(double)),
|
||||
])
|
||||
let px = SIMD3<Float>(0.8, 0.8, 0.8)
|
||||
let a = cdlFirst.evaluate(px) // 0.8*2=1.6 clamp-> lut(1.0)=0.5
|
||||
let b = lutFirst.evaluate(px) // lut(0.8)=0.4 -> *2 = 0.8
|
||||
XCTAssertEqual(a.x, 0.5, accuracy: 1e-4)
|
||||
XCTAssertEqual(b.x, 0.8, accuracy: 1e-4)
|
||||
}
|
||||
|
||||
// Bypassed node skipped.
|
||||
func testBypassSkipsNode() {
|
||||
let double = CDL(slope: SIMD3(2, 2, 2), offset: .zero, power: .one, saturation: 1)
|
||||
var node = GradeNode(kind: .cdl(double))
|
||||
node.isEnabled = false
|
||||
let stack = GradeStack(nodes: [node])
|
||||
let px = SIMD3<Float>(0.25, 0.25, 0.25)
|
||||
XCTAssertEqual(stack.evaluate(px), px)
|
||||
}
|
||||
|
||||
// Saturation node.
|
||||
func testSaturationNode() {
|
||||
let stack = GradeStack(nodes: [GradeNode(kind: .saturation(0))])
|
||||
let out = stack.evaluate(SIMD3(1, 0, 0))
|
||||
XCTAssertEqual(out.x, 0.2126, accuracy: 1e-5)
|
||||
XCTAssertEqual(out.y, 0.2126, accuracy: 1e-5)
|
||||
}
|
||||
|
||||
// Curves node.
|
||||
func testCurvesNode() {
|
||||
var set = CurveSet.identity
|
||||
set.master = Curve(points: [.init(x: 0, y: 0), .init(x: 1, y: 0.5)])
|
||||
let stack = GradeStack(nodes: [GradeNode(kind: .curves(set))])
|
||||
let out = stack.evaluate(SIMD3(1, 1, 1))
|
||||
XCTAssertEqual(out.x, 0.5, accuracy: 1e-5)
|
||||
}
|
||||
|
||||
// Input transform node decodes camera log to linear Rec709.
|
||||
func testInputTransformNode() {
|
||||
let cs = ColorSpace.arriLogC4AWG4
|
||||
let encodedGray = SIMD3<Float>(repeating: cs.transfer.encode(0.18))
|
||||
let stack = GradeStack(nodes: [GradeNode(kind: .inputTransform(.arriLogC4AWG4))])
|
||||
let out = stack.evaluate(encodedGray)
|
||||
XCTAssertEqual(out.x, 0.18, accuracy: 0.002)
|
||||
}
|
||||
|
||||
// Output transform node: linear -> Rec709 display (2.4 gamma).
|
||||
func testOutputTransformNode() {
|
||||
let stack = GradeStack(nodes: [GradeNode(kind: .outputTransform(.rec709Display))])
|
||||
let out = stack.evaluate(SIMD3(0.18, 0.18, 0.18))
|
||||
XCTAssertEqual(out.x, pow(0.18, 1 / 2.4), accuracy: 1e-4)
|
||||
}
|
||||
|
||||
// Full stack on known vector: input LogC4 -> CDL gain -> output 2.4 gamma.
|
||||
func testFullStackKnownVector() {
|
||||
let cs = ColorSpace.arriLogC4AWG4
|
||||
let gain = CDL(slope: SIMD3(2, 2, 2), offset: .zero, power: .one, saturation: 1)
|
||||
let stack = GradeStack(nodes: [
|
||||
GradeNode(kind: .inputTransform(.arriLogC4AWG4)),
|
||||
GradeNode(kind: .cdl(gain)),
|
||||
GradeNode(kind: .outputTransform(.rec709Display)),
|
||||
])
|
||||
let encodedGray = SIMD3<Float>(repeating: cs.transfer.encode(0.18))
|
||||
let out = stack.evaluate(encodedGray)
|
||||
let expected = pow(0.36, 1 / 2.4) // 0.18 * 2 -> display gamma
|
||||
XCTAssertEqual(out.x, Float(expected), accuracy: 0.003)
|
||||
}
|
||||
|
||||
// Node IDs unique + stable.
|
||||
func testNodeIDsUnique() {
|
||||
let a = GradeNode(kind: .saturation(1))
|
||||
let b = GradeNode(kind: .saturation(1))
|
||||
XCTAssertNotEqual(a.id, b.id)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import XCTest
|
||||
|
||||
final class ForgeGradePlaceholderTests: XCTestCase {
|
||||
func testPlaceholder() { XCTAssertTrue(true) }
|
||||
}
|
||||
Loading…
Reference in a new issue