diff --git a/Sources/ForgeColor/Gamut.swift b/Sources/ForgeColor/Gamut.swift new file mode 100644 index 0000000..e23df1c --- /dev/null +++ b/Sources/ForgeColor/Gamut.swift @@ -0,0 +1,83 @@ +import Foundation + +/// 3x3 RGB->RGB gamut conversion matrix (row-major rows). +public struct GamutMatrix: Equatable, Sendable { + public var rows: (SIMD3, SIMD3, SIMD3) + + public init(_ r0: SIMD3, _ r1: SIMD3, _ r2: SIMD3) { + rows = (r0, r1, r2) + } + + public static func == (l: GamutMatrix, r: GamutMatrix) -> Bool { + l.rows.0 == r.rows.0 && l.rows.1 == r.rows.1 && l.rows.2 == r.rows.2 + } + + public func apply(_ v: SIMD3) -> SIMD3 { + SIMD3( + (rows.0 * v).sum(), + (rows.1 * v).sum(), + (rows.2 * v).sum()) + } + + public func inverted() -> GamutMatrix { + let (a, b, c) = (rows.0.x, rows.0.y, rows.0.z) + let (d, e, f) = (rows.1.x, rows.1.y, rows.1.z) + let (g, h, i) = (rows.2.x, rows.2.y, rows.2.z) + let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g) + let inv = 1 / det + return GamutMatrix( + SIMD3((e * i - f * h) * inv, (c * h - b * i) * inv, (b * f - c * e) * inv), + SIMD3((f * g - d * i) * inv, (a * i - c * g) * inv, (c * d - a * f) * inv), + SIMD3((d * h - e * g) * inv, (b * g - a * h) * inv, (a * e - b * d) * inv)) + } + + public static let identity = GamutMatrix(SIMD3(1, 0, 0), SIMD3(0, 1, 0), SIMD3(0, 0, 1)) + + /// ARRI Wide Gamut 4 -> Rec709 (D65). + public static let awg4ToRec709 = GamutMatrix( + SIMD3(1.7245, -0.6421, -0.0824), + SIMD3(-0.1421, 1.1797, -0.0376), + SIMD3(-0.0292, -0.3563, 1.3855)) + + /// REDWideGamutRGB -> Rec709 (D65). + public static let rwgToRec709 = GamutMatrix( + SIMD3(1.66225, -0.588228, -0.0740245), + SIMD3(-0.124545, 1.13307, -0.00852952), + SIMD3(-0.0433403, -0.253201, 1.29654)) + + /// Sony S-Gamut3.Cine -> Rec709 (D65). + public static let sGamut3CineToRec709 = GamutMatrix( + SIMD3(1.6410, -0.3245, -0.3165), + SIMD3(-0.6698, 1.6175, 0.0522), + SIMD3(0.0421, -0.1567, 1.1146)) +} + +/// Camera color space: transfer curve + gamut, with conversion to linear Rec709 working space. +public struct ColorSpace: Equatable, Sendable { + public var name: String + public var transfer: TransferFunction + public var gamutToRec709: GamutMatrix + + public init(name: String, transfer: TransferFunction, gamutToRec709: GamutMatrix) { + self.name = name + self.transfer = transfer + self.gamutToRec709 = gamutToRec709 + } + + /// Camera-log-encoded RGB -> linear Rec709. + public func toLinearRec709(_ encoded: SIMD3) -> SIMD3 { + let lin = SIMD3(transfer.decode(encoded.x), transfer.decode(encoded.y), transfer.decode(encoded.z)) + return gamutToRec709.apply(lin) + } + + /// Linear Rec709 -> camera-log-encoded RGB. + public func fromLinearRec709(_ linear709: SIMD3) -> SIMD3 { + let lin = gamutToRec709.inverted().apply(linear709) + return SIMD3(transfer.encode(lin.x), transfer.encode(lin.y), transfer.encode(lin.z)) + } + + public static let arriLogC4AWG4 = ColorSpace(name: "ARRI LogC4/AWG4", transfer: .logC4, gamutToRec709: .awg4ToRec709) + public static let redLog3G10RWG = ColorSpace(name: "RED Log3G10/RWG", transfer: .log3G10, gamutToRec709: .rwgToRec709) + public static let sonySLog3SGamut3Cine = ColorSpace(name: "Sony S-Log3/SGamut3.Cine", transfer: .sLog3, gamutToRec709: .sGamut3CineToRec709) + public static let rec709 = ColorSpace(name: "Rec709", transfer: .linear, gamutToRec709: .identity) +} diff --git a/Sources/ForgeColor/TransferFunction.swift b/Sources/ForgeColor/TransferFunction.swift new file mode 100644 index 0000000..e2ee4c4 --- /dev/null +++ b/Sources/ForgeColor/TransferFunction.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Camera log transfer functions. Constants from vendor whitepapers: +/// - ARRI LogC4 (ALEXA 35), ARRI LogC4 spec 2022 +/// - RED Log3G10 v3, RED IPP2 whitepaper +/// - Sony S-Log3, Sony technical summary +public enum TransferFunction: String, Sendable, CaseIterable, Codable { + case logC4 + case log3G10 + case sLog3 + case linear + + /// Linear scene value -> encoded log signal (0..1 nominal). + public func encode(_ x: Float) -> Float { + switch self { + case .linear: + return x + case .logC4: + let c = LogC4.self + if x < c.t { return (x - c.t) / c.s } + return (log2(c.a * x + 64) - 6) / 14 * c.b + c.c + case .log3G10: + let c = Log3G10.self + let y = x + c.c + if y < 0 { return y * c.g } + return c.a * log10(y * c.b + 1) + case .sLog3: + let c = SLog3.self + if x >= c.linBreak { + return (420 + log10((x + 0.01) / 0.19) * 261.5) / 1023 + } + return (x * (171.2102946929 - 95) / c.linBreak + 95) / 1023 + } + } + + /// Encoded log signal -> linear scene value. + public func decode(_ y: Float) -> Float { + switch self { + case .linear: + return y + case .logC4: + let c = LogC4.self + if y < 0 { return y * c.s + c.t } + return (exp2(14 * (y - c.c) / c.b + 6) - 64) / c.a + case .log3G10: + let c = Log3G10.self + if y < 0 { return y / c.g - c.c } + return (pow(10, y / c.a) - 1) / c.b - c.c + case .sLog3: + let c = SLog3.self + if y >= 171.2102946929 / 1023 { + return pow(10, (y * 1023 - 420) / 261.5) * 0.19 - 0.01 + } + return (y * 1023 - 95) * c.linBreak / (171.2102946929 - 95) + } + } + + // MARK: Constants + + private enum LogC4 { + static let a: Float = (exp2(18) - 16) / 117.45 + static let b: Float = (1023 - 95) / 1023 + static let c: Float = 95.0 / 1023.0 + static let s: Float = (7 * log(2.0 as Float) * exp2(7 - 14 * c / b)) / (a * b) + static let t: Float = (exp2(14 * (-c / b) + 6) - 64) / a + } + + private enum Log3G10 { + static let a: Float = 0.224282 + static let b: Float = 155.975327 + static let c: Float = 0.01 + static let g: Float = 15.1927 + } + + private enum SLog3 { + static let linBreak: Float = 0.01125000 + } +} diff --git a/Tests/ForgeColorTests/TransferFunctionTests.swift b/Tests/ForgeColorTests/TransferFunctionTests.swift new file mode 100644 index 0000000..62a6078 --- /dev/null +++ b/Tests/ForgeColorTests/TransferFunctionTests.swift @@ -0,0 +1,102 @@ +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) + } +}