color: LogC4/Log3G10/S-Log3 transfer fns + AWG4/RWG/SGamut3.Cine gamut matrices, anchor-value verified — 11 tests
This commit is contained in:
parent
ac3cfe9815
commit
e1e189378b
3 changed files with 263 additions and 0 deletions
83
Sources/ForgeColor/Gamut.swift
Normal file
83
Sources/ForgeColor/Gamut.swift
Normal 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)
|
||||
}
|
||||
78
Sources/ForgeColor/TransferFunction.swift
Normal file
78
Sources/ForgeColor/TransferFunction.swift
Normal 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
|
||||
}
|
||||
}
|
||||
102
Tests/ForgeColorTests/TransferFunctionTests.swift
Normal file
102
Tests/ForgeColorTests/TransferFunctionTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue