color: LogC4/Log3G10/S-Log3 transfer fns + AWG4/RWG/SGamut3.Cine gamut matrices, anchor-value verified — 11 tests

This commit is contained in:
Forge Dev 2026-07-10 18:48:20 +00:00
parent ac3cfe9815
commit e1e189378b
3 changed files with 263 additions and 0 deletions

View file

@ -0,0 +1,83 @@
import Foundation
/// 3x3 RGB->RGB gamut conversion matrix (row-major rows).
public struct GamutMatrix: Equatable, Sendable {
public var rows: (SIMD3<Float>, SIMD3<Float>, SIMD3<Float>)
public init(_ r0: SIMD3<Float>, _ r1: SIMD3<Float>, _ r2: SIMD3<Float>) {
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<Float>) -> SIMD3<Float> {
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<Float>) -> SIMD3<Float> {
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<Float>) -> SIMD3<Float> {
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)
}

View file

@ -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
}
}

View file

@ -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<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)
}
}