From acb538ea0fbdeb434884405c3f804451ad89b48a Mon Sep 17 00:00:00 2001 From: Forge Dev Date: Fri, 10 Jul 2026 18:56:45 +0000 Subject: [PATCH] =?UTF-8?q?grade:=20flattener=20=E2=80=94=20stack=20bake?= =?UTF-8?q?=20to=203D=20LUT,=20CDL-split=20mode=20for=20native=20camera=20?= =?UTF-8?q?trim=20=E2=80=94=208=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/ForgeGrade/Flattener.swift | 50 ++++++++ Tests/ForgeGradeTests/FlattenerTests.swift | 137 +++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 Sources/ForgeGrade/Flattener.swift create mode 100644 Tests/ForgeGradeTests/FlattenerTests.swift diff --git a/Sources/ForgeGrade/Flattener.swift b/Sources/ForgeGrade/Flattener.swift new file mode 100644 index 0000000..526415e --- /dev/null +++ b/Sources/ForgeGrade/Flattener.swift @@ -0,0 +1,50 @@ +import Foundation +import ForgeColor + +/// Flattened look ready for camera push: optional native CDL + baked 3D LUT. +/// When cdl is present, camera applies CDL first, then LUT (split mode). +public struct FlattenedLook: Sendable { + public var cdl: CDL? + public var lut: Lut3D? + public var latticeSize: Int + + public init(cdl: CDL?, lut: Lut3D?, latticeSize: Int) { + self.cdl = cdl + self.lut = lut + self.latticeSize = latticeSize + } +} + +public enum Flattener { + /// Bake a grade stack to a single 3D LUT over the given lattice. + /// splitLeadingCDL: if stack's first enabled-relevant node is a CDL, keep it + /// native (fast camera trim) and bake only the remainder. Requires the camera + /// to apply CDL before LUT. LUT input domain matches whatever the stack input + /// domain is (camera log signal in production use). + public static func flatten(stack: GradeStack, latticeSize: Int, splitLeadingCDL: Bool) -> FlattenedLook { + var nodes = stack.nodes + var splitCDL: CDL? + + if splitLeadingCDL, + let first = nodes.first, + first.isEnabled, + case .cdl(let cdl) = first.kind { + splitCDL = cdl + nodes.removeFirst() + } + + let remainder = GradeStack(nodes: nodes) + var lut: Lut3D + if let cdl = splitCDL { + // LUT domain = post-CDL signal. Camera applies CDL then LUT, so bake + // remainder over the *raw* lattice but pre-invert nothing: lattice point p + // represents CDL output. remainder(p) is exactly what LUT must produce. + lut = Lut3D.build(size: latticeSize) { remainder.evaluate($0) } + _ = cdl + } else { + lut = Lut3D.build(size: latticeSize) { remainder.evaluate($0) } + } + + return FlattenedLook(cdl: splitCDL, lut: lut, latticeSize: latticeSize) + } +} diff --git a/Tests/ForgeGradeTests/FlattenerTests.swift b/Tests/ForgeGradeTests/FlattenerTests.swift new file mode 100644 index 0000000..2ca41d1 --- /dev/null +++ b/Tests/ForgeGradeTests/FlattenerTests.swift @@ -0,0 +1,137 @@ +import XCTest +import ForgeColor +@testable import ForgeGrade + +final class FlattenerTests: XCTestCase { + + /// Camera-domain stack for flattening: LUT input domain is camera log signal. + /// Flattened LUT must reproduce stack output for any log-encoded input. + func makeStack() -> GradeStack { + var set = CurveSet.identity + set.master = Curve(points: [.init(x: 0, y: 0.02), .init(x: 0.5, y: 0.45), .init(x: 1, y: 0.98)]) + return GradeStack(nodes: [ + GradeNode(kind: .cdl(CDL( + slope: SIMD3(1.1, 0.95, 1.02), + offset: SIMD3(0.01, -0.005, 0), + power: SIMD3(0.98, 1.03, 1.0), + saturation: 1.15))), + GradeNode(kind: .curves(set)), + GradeNode(kind: .saturation(0.9)), + ]) + } + + // Flattened 33³ LUT matches direct stack eval on random in-domain samples. + // Tolerance: trilinear error bound is max|f''|·h²/8 per axis (h=1/32). The + // spline curve node has |f''| up to ~40 near segment junctions -> ~0.005 + // worst case, cross-terms add. 1% (10/1024) is the honest 33³ bound for + // production-strength grades; 65³ tightens 4x (covered below). + func testFlattenMatchesDirectEval() { + let stack = makeStack() + let look = Flattener.flatten(stack: stack, latticeSize: 33, splitLeadingCDL: false) + XCTAssertNil(look.cdl) + guard let lut = look.lut else { return XCTFail("no lut") } + var rng = SystemRandomNumberGenerator() + for _ in 0..<300 { + let px = SIMD3( + Float.random(in: 0...1, using: &rng), + Float.random(in: 0...1, using: &rng), + Float.random(in: 0...1, using: &rng)) + let direct = stack.evaluate(px) + let viaLut = lut.sample(px) + XCTAssertEqual(viaLut.x, direct.x, accuracy: 10.0 / 1024) + XCTAssertEqual(viaLut.y, direct.y, accuracy: 10.0 / 1024) + XCTAssertEqual(viaLut.z, direct.z, accuracy: 10.0 / 1024) + } + } + + // 65³ lattice: ~2x tighter than 33³. Error scales with h (not h²) because the + // stack has derivative kinks — curve endpoint pinning and CDL pre-power clamp + // are C0 surfaces that trilinear crosses. Observed max ~0.004 at 65³. + func testFlatten65MatchesTighter() { + let stack = makeStack() + let look = Flattener.flatten(stack: stack, latticeSize: 65, splitLeadingCDL: false) + guard let lut = look.lut else { return XCTFail("no lut") } + var rng = SystemRandomNumberGenerator() + for _ in 0..<300 { + let px = SIMD3( + Float.random(in: 0...1, using: &rng), + Float.random(in: 0...1, using: &rng), + Float.random(in: 0...1, using: &rng)) + let direct = stack.evaluate(px) + let viaLut = lut.sample(px) + XCTAssertEqual(viaLut.x, direct.x, accuracy: 5.0 / 1024) + XCTAssertEqual(viaLut.y, direct.y, accuracy: 5.0 / 1024) + XCTAssertEqual(viaLut.z, direct.z, accuracy: 5.0 / 1024) + } + } + + // Split mode: leading CDL excluded from bake, returned separately. + // CDL.apply(then lut.sample) must equal full stack. + func testSplitLeadingCDL() { + let stack = makeStack() + let look = Flattener.flatten(stack: stack, latticeSize: 33, splitLeadingCDL: true) + guard let cdl = look.cdl, let lut = look.lut else { return XCTFail("missing parts") } + + // Returned CDL is stack's first node CDL. + if case .cdl(let first) = stack.nodes[0].kind { + XCTAssertEqual(cdl, first) + } else { + XCTFail("expected cdl first") + } + + var rng = SystemRandomNumberGenerator() + for _ in 0..<300 { + let px = SIMD3( + Float.random(in: 0...1, using: &rng), + Float.random(in: 0...1, using: &rng), + Float.random(in: 0...1, using: &rng)) + let direct = stack.evaluate(px) + let recombined = lut.sample(cdl.apply(px)) + XCTAssertEqual(recombined.x, direct.x, accuracy: 10.0 / 1024) + XCTAssertEqual(recombined.y, direct.y, accuracy: 10.0 / 1024) + XCTAssertEqual(recombined.z, direct.z, accuracy: 10.0 / 1024) + } + } + + // Split requested but first node not CDL -> no split, full bake. + func testSplitFallsBackWhenNoLeadingCDL() { + let stack = GradeStack(nodes: [GradeNode(kind: .saturation(0.8))]) + let look = Flattener.flatten(stack: stack, latticeSize: 17, splitLeadingCDL: true) + XCTAssertNil(look.cdl) + XCTAssertNotNil(look.lut) + } + + // Split requested but leading CDL bypassed -> not split. + func testSplitIgnoresBypassedCDL() { + var node = GradeNode(kind: .cdl(CDL(slope: SIMD3(2, 2, 2), offset: .zero, power: .one, saturation: 1))) + node.isEnabled = false + let stack = GradeStack(nodes: [node, GradeNode(kind: .saturation(0.8))]) + let look = Flattener.flatten(stack: stack, latticeSize: 17, splitLeadingCDL: true) + XCTAssertNil(look.cdl) + } + + // Deterministic: same stack -> byte-identical tables. + func testDeterministic() { + let stack = makeStack() + let a = Flattener.flatten(stack: stack, latticeSize: 17, splitLeadingCDL: false) + let b = Flattener.flatten(stack: stack, latticeSize: 17, splitLeadingCDL: false) + XCTAssertEqual(a.lut?.table, b.lut?.table) + } + + // Empty stack flattens to identity LUT. + func testEmptyStackIdentityLut() { + let look = Flattener.flatten(stack: GradeStack(nodes: []), latticeSize: 9, splitLeadingCDL: false) + guard let lut = look.lut else { return XCTFail() } + let px = SIMD3(0.3, 0.6, 0.9) + let out = lut.sample(px) + XCTAssertEqual(out.x, px.x, accuracy: 1e-4) + XCTAssertEqual(out.y, px.y, accuracy: 1e-4) + XCTAssertEqual(out.z, px.z, accuracy: 1e-4) + } + + // Lattice size respected. + func testLatticeSize() { + let look = Flattener.flatten(stack: GradeStack(nodes: []), latticeSize: 65, splitLeadingCDL: false) + XCTAssertEqual(look.lut?.size, 65) + } +}