rainbow-dragon/Tests/ForgeColorTests/TransferFunctionTests.swift

103 lines
3.7 KiB
Swift
Raw Normal View History

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<Float>(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<Float>(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<Float>(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)
}
}