102 lines
3.7 KiB
Swift
102 lines
3.7 KiB
Swift
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)
|
|
}
|
|
}
|