import XCTest @testable import ForgeColor final class TransferFunctionTests: XCTestCase { let samples: [Float] = [0.0, 0.001, 0.01, 0.045, 0.18, 0.5, 1.0, 4.0, 12.0] // MARK: Round trips lin -> log -> lin func testLogC4RoundTrip() { for x in samples { let y = TransferFunction.logC4.encode(x) let back = TransferFunction.logC4.decode(y) XCTAssertEqual(back, x, accuracy: max(1e-4, x * 1e-4), "LogC4 round trip at \(x)") } } func testLog3G10RoundTrip() { for x in samples { let y = TransferFunction.log3G10.encode(x) let back = TransferFunction.log3G10.decode(y) XCTAssertEqual(back, x, accuracy: max(1e-4, x * 1e-4), "Log3G10 round trip at \(x)") } } func testSLog3RoundTrip() { for x in samples { let y = TransferFunction.sLog3.encode(x) let back = TransferFunction.sLog3.decode(y) XCTAssertEqual(back, x, accuracy: max(1e-4, x * 1e-4), "S-Log3 round trip at \(x)") } } // MARK: Published anchor values // ARRI LogC4: 18% gray encodes to ~0.2783 (27.8% signal). func testLogC4MidGrayAnchor() { XCTAssertEqual(TransferFunction.logC4.encode(0.18), 0.2783, accuracy: 0.002) } // LogC4 encodes 0.0 to a small positive-ish value near 0.0929 region minus... // Zero linear must decode back to zero. func testLogC4ZeroStable() { let y = TransferFunction.logC4.encode(0) XCTAssertEqual(TransferFunction.logC4.decode(y), 0, accuracy: 1e-6) } // RED Log3G10: designed so 18% gray -> exactly 1/3. func testLog3G10MidGrayAnchor() { XCTAssertEqual(TransferFunction.log3G10.encode(0.18), 1.0 / 3.0, accuracy: 0.001) } // Sony S-Log3: 18% gray -> 420/1023. func testSLog3MidGrayAnchor() { XCTAssertEqual(TransferFunction.sLog3.encode(0.18), 420.0 / 1023.0, accuracy: 0.001) } // Monotonic increasing over sample range. func testMonotonic() { for tf in [TransferFunction.logC4, .log3G10, .sLog3] { var prev = -Float.infinity for x in samples { let y = tf.encode(x) XCTAssertGreaterThan(y, prev, "\(tf) not monotonic at \(x)") prev = y } } } // MARK: Gamut matrices // Camera gamut -> Rec709 matrices must preserve white: (1,1,1) -> (1,1,1). func testGamutMatricesPreserveWhite() { for m in [GamutMatrix.awg4ToRec709, .rwgToRec709, .sGamut3CineToRec709] { let w = m.apply(SIMD3(1, 1, 1)) XCTAssertEqual(w.x, 1, accuracy: 1e-3) XCTAssertEqual(w.y, 1, accuracy: 1e-3) XCTAssertEqual(w.z, 1, accuracy: 1e-3) } } // Inverse matrix round-trips a color. func testGamutMatrixInverseRoundTrip() { let px = SIMD3(0.4, 0.25, 0.7) for m in [GamutMatrix.awg4ToRec709, .rwgToRec709, .sGamut3CineToRec709] { let back = m.inverted().apply(m.apply(px)) XCTAssertEqual(back.x, px.x, accuracy: 1e-4) XCTAssertEqual(back.y, px.y, accuracy: 1e-4) XCTAssertEqual(back.z, px.z, accuracy: 1e-4) } } // ColorSpace bundles: full lin conversion pipeline camera-log -> linear Rec709. func testColorSpaceDecodeToLinearRec709() { // LogC4-encoded 18% gray decodes through AWG4->Rec709 to ~0.18 gray (neutral axis unchanged by matrix). let cs = ColorSpace.arriLogC4AWG4 let gray = cs.toLinearRec709(SIMD3(repeating: cs.transfer.encode(0.18))) XCTAssertEqual(gray.x, 0.18, accuracy: 0.002) XCTAssertEqual(gray.y, 0.18, accuracy: 0.002) XCTAssertEqual(gray.z, 0.18, accuracy: 0.002) } }