color: Lut3D trilinear lattice + .cube parse/write with validation — 10 tests
This commit is contained in:
parent
e1e189378b
commit
0b4808f67c
2 changed files with 252 additions and 0 deletions
132
Sources/ForgeColor/Lut3D.swift
Normal file
132
Sources/ForgeColor/Lut3D.swift
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 3D LUT lattice. Table layout matches .cube: red index fastest, then green, then blue.
|
||||||
|
/// Entry (r,g,b) at index b*size² + g*size + r, three floats per entry.
|
||||||
|
public struct Lut3D: Sendable {
|
||||||
|
public let size: Int
|
||||||
|
/// size³ * 3 floats.
|
||||||
|
public let table: [Float]
|
||||||
|
|
||||||
|
public init(size: Int, table: [Float]) {
|
||||||
|
precondition(size >= 2, "LUT size must be >= 2")
|
||||||
|
precondition(table.count == size * size * size * 3, "table count mismatch")
|
||||||
|
self.size = size
|
||||||
|
self.table = table
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func identity(size: Int) -> Lut3D {
|
||||||
|
build(size: size) { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build lattice by evaluating a function at each grid point.
|
||||||
|
public static func build(size: Int, function: (SIMD3<Float>) -> SIMD3<Float>) -> Lut3D {
|
||||||
|
var table = [Float]()
|
||||||
|
table.reserveCapacity(size * size * size * 3)
|
||||||
|
let denom = Float(size - 1)
|
||||||
|
for b in 0..<size {
|
||||||
|
for g in 0..<size {
|
||||||
|
for r in 0..<size {
|
||||||
|
let out = function(SIMD3(Float(r) / denom, Float(g) / denom, Float(b) / denom))
|
||||||
|
table.append(out.x)
|
||||||
|
table.append(out.y)
|
||||||
|
table.append(out.z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Lut3D(size: size, table: table)
|
||||||
|
}
|
||||||
|
|
||||||
|
@inline(__always)
|
||||||
|
private func entry(_ r: Int, _ g: Int, _ b: Int) -> SIMD3<Float> {
|
||||||
|
let i = ((b * size + g) * size + r) * 3
|
||||||
|
return SIMD3(table[i], table[i + 1], table[i + 2])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trilinear interpolation; input clamped to [0,1].
|
||||||
|
public func sample(_ rgb: SIMD3<Float>) -> SIMD3<Float> {
|
||||||
|
let maxIdx = Float(size - 1)
|
||||||
|
let clamped = SIMD3(
|
||||||
|
min(max(rgb.x, 0), 1),
|
||||||
|
min(max(rgb.y, 0), 1),
|
||||||
|
min(max(rgb.z, 0), 1))
|
||||||
|
let pos = clamped * maxIdx
|
||||||
|
let i0 = SIMD3<Int>(Int(pos.x), Int(pos.y), Int(pos.z))
|
||||||
|
let i1 = SIMD3<Int>(
|
||||||
|
min(i0.x + 1, size - 1),
|
||||||
|
min(i0.y + 1, size - 1),
|
||||||
|
min(i0.z + 1, size - 1))
|
||||||
|
let f = pos - SIMD3(Float(i0.x), Float(i0.y), Float(i0.z))
|
||||||
|
|
||||||
|
// 8 corners; lerp r, then g, then b.
|
||||||
|
let c000 = entry(i0.x, i0.y, i0.z), c100 = entry(i1.x, i0.y, i0.z)
|
||||||
|
let c010 = entry(i0.x, i1.y, i0.z), c110 = entry(i1.x, i1.y, i0.z)
|
||||||
|
let c001 = entry(i0.x, i0.y, i1.z), c101 = entry(i1.x, i0.y, i1.z)
|
||||||
|
let c011 = entry(i0.x, i1.y, i1.z), c111 = entry(i1.x, i1.y, i1.z)
|
||||||
|
|
||||||
|
let c00 = c000 + (c100 - c000) * f.x
|
||||||
|
let c10 = c010 + (c110 - c010) * f.x
|
||||||
|
let c01 = c001 + (c101 - c001) * f.x
|
||||||
|
let c11 = c011 + (c111 - c011) * f.x
|
||||||
|
let c0 = c00 + (c10 - c00) * f.y
|
||||||
|
let c1 = c01 + (c11 - c01) * f.y
|
||||||
|
return c0 + (c1 - c0) * f.z
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - .cube I/O
|
||||||
|
|
||||||
|
public enum CubeError: Error, Equatable {
|
||||||
|
case missingSize
|
||||||
|
case invalidFloat(line: Int)
|
||||||
|
case entryCountMismatch(expected: Int, got: Int)
|
||||||
|
case invalidSize
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CubeFile {
|
||||||
|
/// Serialize to Resolve/IRIDAS .cube text.
|
||||||
|
public static func write(_ lut: Lut3D, title: String = "Forge") -> String {
|
||||||
|
var out = "TITLE \"\(title)\"\nLUT_3D_SIZE \(lut.size)\n"
|
||||||
|
out.reserveCapacity(lut.table.count * 10)
|
||||||
|
var i = 0
|
||||||
|
while i < lut.table.count {
|
||||||
|
out += String(format: "%.6f %.6f %.6f\n", lut.table[i], lut.table[i + 1], lut.table[i + 2])
|
||||||
|
i += 3
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse .cube text. Ignores TITLE/DOMAIN_MIN/DOMAIN_MAX/comments/blank lines.
|
||||||
|
public static func parse(_ text: String) throws -> Lut3D {
|
||||||
|
var size: Int?
|
||||||
|
var values = [Float]()
|
||||||
|
var lineNo = 0
|
||||||
|
for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||||
|
lineNo += 1
|
||||||
|
let line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||||
|
if line.isEmpty || line.hasPrefix("#") { continue }
|
||||||
|
if line.hasPrefix("TITLE") || line.hasPrefix("DOMAIN_MIN") || line.hasPrefix("DOMAIN_MAX")
|
||||||
|
|| line.hasPrefix("LUT_1D_SIZE") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line.hasPrefix("LUT_3D_SIZE") {
|
||||||
|
let parts = line.split(separator: " ")
|
||||||
|
guard parts.count == 2, let s = Int(parts[1]), s >= 2 else { throw CubeError.invalidSize }
|
||||||
|
size = s
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Data row: three floats.
|
||||||
|
let comps = line.split(separator: " ", omittingEmptySubsequences: true)
|
||||||
|
guard comps.count == 3 else { throw CubeError.invalidFloat(line: lineNo) }
|
||||||
|
for c in comps {
|
||||||
|
guard let v = Float(c) else { throw CubeError.invalidFloat(line: lineNo) }
|
||||||
|
values.append(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let s = size else { throw CubeError.missingSize }
|
||||||
|
let expected = s * s * s * 3
|
||||||
|
guard values.count == expected else {
|
||||||
|
throw CubeError.entryCountMismatch(expected: expected, got: values.count)
|
||||||
|
}
|
||||||
|
return Lut3D(size: s, table: values)
|
||||||
|
}
|
||||||
|
}
|
||||||
120
Tests/ForgeColorTests/Lut3DTests.swift
Normal file
120
Tests/ForgeColorTests/Lut3DTests.swift
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import XCTest
|
||||||
|
@testable import ForgeColor
|
||||||
|
|
||||||
|
final class Lut3DTests: XCTestCase {
|
||||||
|
|
||||||
|
// Identity lattice is a no-op within interp tolerance.
|
||||||
|
func testIdentityNoOp() {
|
||||||
|
let lut = Lut3D.identity(size: 17)
|
||||||
|
for px in [SIMD3<Float>(0, 0, 0), SIMD3(1, 1, 1), SIMD3(0.5, 0.25, 0.75), SIMD3(0.18, 0.18, 0.18)] {
|
||||||
|
let out = lut.sample(px)
|
||||||
|
XCTAssertEqual(out.x, px.x, accuracy: 1e-5)
|
||||||
|
XCTAssertEqual(out.y, px.y, accuracy: 1e-5)
|
||||||
|
XCTAssertEqual(out.z, px.z, accuracy: 1e-5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lattice built from an analytic function: interp matches direct eval on off-lattice points.
|
||||||
|
func testTrilinearMatchesDirectEval() {
|
||||||
|
// f = gain 0.5 (linear function -> trilinear should be near-exact)
|
||||||
|
let f: (SIMD3<Float>) -> SIMD3<Float> = { $0 * 0.5 }
|
||||||
|
let lut = Lut3D.build(size: 9, function: f)
|
||||||
|
for px in [SIMD3<Float>(0.13, 0.57, 0.91), SIMD3(0.9, 0.05, 0.5)] {
|
||||||
|
let out = lut.sample(px)
|
||||||
|
let direct = f(px)
|
||||||
|
XCTAssertEqual(out.x, direct.x, accuracy: 1e-4)
|
||||||
|
XCTAssertEqual(out.y, direct.y, accuracy: 1e-4)
|
||||||
|
XCTAssertEqual(out.z, direct.z, accuracy: 1e-4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nonlinear function: 33^3 approx within 1/1024 where trilinear error bound
|
||||||
|
// (max|f''|·h²/8, h=1/32) actually permits it. pow(x, 0.6) has f''→∞ at 0;
|
||||||
|
// at x=0.15, |f''|≈3.4 → bound ≈4e-4. Real flattening runs in log space where
|
||||||
|
// curvature is gentle everywhere. Samples in [0.15, 1].
|
||||||
|
func testNonlinearApproxWithinTolerance() {
|
||||||
|
let f: (SIMD3<Float>) -> SIMD3<Float> = {
|
||||||
|
SIMD3(pow($0.x, 2.2), pow($0.y, 1.8), pow($0.z, 0.6))
|
||||||
|
}
|
||||||
|
let lut = Lut3D.build(size: 33, function: f)
|
||||||
|
var rng = SystemRandomNumberGenerator()
|
||||||
|
for _ in 0..<200 {
|
||||||
|
let px = SIMD3<Float>(
|
||||||
|
Float.random(in: 0.15...1, using: &rng),
|
||||||
|
Float.random(in: 0.15...1, using: &rng),
|
||||||
|
Float.random(in: 0.15...1, using: &rng))
|
||||||
|
let out = lut.sample(px)
|
||||||
|
let direct = f(px)
|
||||||
|
XCTAssertEqual(out.x, direct.x, accuracy: 1.0 / 1024)
|
||||||
|
XCTAssertEqual(out.y, direct.y, accuracy: 1.0 / 1024)
|
||||||
|
XCTAssertEqual(out.z, direct.z, accuracy: 1.0 / 1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Out-of-range input clamps to lattice bounds.
|
||||||
|
func testInputClamped() {
|
||||||
|
let lut = Lut3D.identity(size: 5)
|
||||||
|
let out = lut.sample(SIMD3(-0.5, 1.5, 0.5))
|
||||||
|
XCTAssertEqual(out.x, 0, accuracy: 1e-5)
|
||||||
|
XCTAssertEqual(out.y, 1, accuracy: 1e-5)
|
||||||
|
XCTAssertEqual(out.z, 0.5, accuracy: 1e-5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: CUBE I/O
|
||||||
|
|
||||||
|
func testCubeRoundTrip() throws {
|
||||||
|
let lut = Lut3D.build(size: 5) { $0 * 0.8 + SIMD3(0.05, 0, 0.1) }
|
||||||
|
let text = CubeFile.write(lut, title: "Forge Test")
|
||||||
|
let parsed = try CubeFile.parse(text)
|
||||||
|
XCTAssertEqual(parsed.size, 5)
|
||||||
|
for i in 0..<lut.table.count {
|
||||||
|
XCTAssertEqual(parsed.table[i], lut.table[i], accuracy: 1e-6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCubeWriteFormat() {
|
||||||
|
let lut = Lut3D.identity(size: 2)
|
||||||
|
let text = CubeFile.write(lut, title: "T")
|
||||||
|
XCTAssertTrue(text.contains("TITLE \"T\""))
|
||||||
|
XCTAssertTrue(text.contains("LUT_3D_SIZE 2"))
|
||||||
|
// First lattice entry (0,0,0), red fastest.
|
||||||
|
XCTAssertTrue(text.contains("0.000000 0.000000 0.000000"))
|
||||||
|
XCTAssertTrue(text.hasSuffix("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCubeParseSkipsCommentsAndDomain() throws {
|
||||||
|
let text = """
|
||||||
|
# comment line
|
||||||
|
TITLE "X"
|
||||||
|
LUT_3D_SIZE 2
|
||||||
|
DOMAIN_MIN 0.0 0.0 0.0
|
||||||
|
DOMAIN_MAX 1.0 1.0 1.0
|
||||||
|
|
||||||
|
0 0 0
|
||||||
|
1 0 0
|
||||||
|
0 1 0
|
||||||
|
1 1 0
|
||||||
|
0 0 1
|
||||||
|
1 0 1
|
||||||
|
0 1 1
|
||||||
|
1 1 1
|
||||||
|
"""
|
||||||
|
let lut = try CubeFile.parse(text)
|
||||||
|
XCTAssertEqual(lut.size, 2)
|
||||||
|
XCTAssertEqual(lut.sample(SIMD3(1, 1, 1)).x, 1, accuracy: 1e-6)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCubeParseRejectsMissingSize() {
|
||||||
|
XCTAssertThrowsError(try CubeFile.parse("0 0 0\n1 1 1\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCubeParseRejectsWrongEntryCount() {
|
||||||
|
let text = "LUT_3D_SIZE 2\n0 0 0\n1 1 1\n" // needs 8 entries, has 2
|
||||||
|
XCTAssertThrowsError(try CubeFile.parse(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCubeParseRejectsBadFloat() {
|
||||||
|
let text = "LUT_3D_SIZE 2\n" + Array(repeating: "0 0 zebra", count: 8).joined(separator: "\n")
|
||||||
|
XCTAssertThrowsError(try CubeFile.parse(text))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue