diff --git a/Sources/CoreRaster/Tone/AutoTone.swift b/Sources/CoreRaster/Tone/AutoTone.swift index 7deac1e1..2ffd1e93 100644 --- a/Sources/CoreRaster/Tone/AutoTone.swift +++ b/Sources/CoreRaster/Tone/AutoTone.swift @@ -1,8 +1,18 @@ // AutoTone.swift: the levels and white balance auto tone suggests from a // buffer's histogram, the tone-pack contract slice -// (docs/blueprints/core/features/tone-pack.md). -// TP-0 stub: `suggest` throws `RasterError.invalid("not built")` until TP-6 -// fills it and turns on `AutoToneTests`. +// (docs/blueprints/core/features/tone-pack.md). A suggestion is applied as +// `whiteBalance(temperature:tint:)` then linear `levels` with output 0...1. +// Working space: straight linear values, clamped to 0...1, with Rec. 709 luma +// (`Histogram`). Fully transparent pixels are ignored. Degenerate input never +// throws or gives NaN: it suggests the neutral (identity) parts instead. +#if canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#elseif canImport(Darwin) +import Darwin +#endif + public struct AutoTone: Hashable, Sendable { public var inBlack: Float, inWhite: Float, gamma: Float, temperature: Float, tint: Float @@ -14,9 +24,84 @@ public struct AutoTone: Hashable, Sendable { self.tint = tint } - /// Levels (black and white points at 0.1% and 99.9%, a gamma) and a white - /// balance that neutralise the buffer's cast. + /// The share of pixels clipped at each end of the luma histogram. + static let clip = 0.001 + /// The brightest share of pixels whose mean sets the white balance. + static let brightShare = 0.05 + /// Levels whose black and white points are closer than this (a flat + /// image) are left at identity. + static let minimumSpan = 1.0 / 256 + static let bins = 4096 + + /// Levels (black and white points at 0.1% and 99.9% of the luma + /// histogram, a gamma that puts the median at 0.5) and a white balance + /// that neutralises the mean of the brightest 5% of pixels with no channel + /// at 0 or 1, mapped back through `whiteBalance`'s own gains. The levels + /// are read after that white balance. Neutral parts: temperature and tint + /// 0 when no pixel is in that bright set (an empty, black or blown-out + /// buffer); levels 0, 1, 1 when every pixel is transparent or the black + /// and white points are under 1/256 apart. Never throws for a buffer. public static func suggest(for buffer: PixelBuffer) throws -> AutoTone { - throw RasterError.invalid("not built") + let balance = try whiteBalance(for: buffer) + let balanced = try buffer.whiteBalance(temperature: balance.temperature, tint: balance.tint) + let luma = try Histogram(of: balanced, bins: bins).luminance + var tone = AutoTone(inBlack: 0, inWhite: 1, gamma: 1, temperature: balance.temperature, tint: balance.tint) + guard let black = quantile(luma, clip), let white = quantile(luma, 1 - clip), + let median = quantile(luma, 0.5), white - black >= minimumSpan else { return tone } + tone.inBlack = Float(black) + tone.inWhite = Float(white) + // Levels maps t = (median − black) / span to t^(1/gamma); 0.5 wants this gamma. + let t = (median - black) / (white - black) + if t > 0, t < 1 { tone.gamma = Float(min(max(log(t) / log(0.5), 0.1), 10)) } + return tone + } + + /// The temperature and tint whose `whiteBalance` gains make the bright + /// set's mean grey: gains 2^(T/200), 2^(−M/200), 2^(−T/200) times one + /// norm, so T = 100·log2(b/r) and M = 100·log2(g²/(r·b)), clamped to the + /// slider range. + static func whiteBalance(for buffer: PixelBuffer) throws -> (temperature: Float, tint: Float) { + let luma = try Histogram(of: buffer, bins: bins).luminance + guard let threshold = quantile(luma, 1 - brightShare) else { return (0, 0) } + let c = buffer.components, w = Histogram.luminanceWeights + var sum = SIMD3.zero, count = 0 + for i in stride(from: 0, to: c.count, by: PixelBuffer.channels) where c[i + 3] != 0 { + let s = RGBA(r: c[i], g: c[i + 1], b: c[i + 2], a: c[i + 3]).unpremultiplied() + let rgb = SIMD3(Double(s.r), Double(s.g), Double(s.b)) + guard rgb.min() > 0, rgb.max() < 1, + Double(w.r * s.r + w.g * s.g + w.b * s.b) >= threshold else { continue } + sum += rgb + count += 1 + } + guard count > 0 else { return (0, 0) } + let mean = sum / Double(count) + let temperature = 100 * log2(mean.z / mean.x) + let tint = 100 * log2(mean.y * mean.y / (mean.x * mean.z)) + return (Float(min(max(temperature, -100), 100)), Float(min(max(tint, -100), 100))) + } + + /// The value below which share `p` of the counted values lie, in 0...1: + /// the mean of that rank read from the bottom and from the top, each + /// interpolated within its bin, so a symmetric histogram's median is its + /// centre. Nil when nothing is counted. + static func quantile(_ counts: [Int], _ p: Double) -> Double? { + guard let low = rank(counts, p), let high = rank(counts.reversed(), 1 - p) else { return nil } + return (low + 1 - high) / 2 + } + + /// The bin position, as a share of 0...1, where the running count from + /// the first bin reaches share `p` of the total. Nil for an empty histogram. + static func rank(_ counts: [Int], _ p: Double) -> Double? { + let total = counts.reduce(0, +) + guard total > 0 else { return nil } + let target = p * Double(total) + var below = 0.0 + for (k, n) in counts.enumerated() where n > 0 { + if below + Double(n) >= target { + return (Double(k) + (target - below) / Double(n)) / Double(counts.count) + } + below += Double(n) + } + return 1 } } diff --git a/Tests/CoreRasterTests/AutoToneTests.swift b/Tests/CoreRasterTests/AutoToneTests.swift index 39c253e9..6ca3e8be 100644 --- a/Tests/CoreRasterTests/AutoToneTests.swift +++ b/Tests/CoreRasterTests/AutoToneTests.swift @@ -1,7 +1,6 @@ // AutoToneTests.swift: tone-pack TP-6's acceptance, written first (TP-1 of // docs/blueprints/core/features/tone-pack.md): `AutoTone.suggest`. A suggestion -// is applied as `whiteBalance` then `levels` (linear, output 0...1). Disabled -// until TP-6 lands. +// is applied as `whiteBalance` then `levels` (linear, output 0...1). import CoreRaster import Testing @@ -22,7 +21,7 @@ import Testing return sum / Double(p.width * p.height) } - @Test(.disabled("waits on TP-6")) func castNeutralised() throws { + @Test func castNeutralised() throws { let photo = try ToneFixtures.castPhoto() let t = try AutoTone.suggest(for: photo.buffer) let fixed = try photo.buffer.whiteBalance(temperature: t.temperature, tint: t.tint) @@ -33,7 +32,7 @@ import Testing /// A linear grey ramp, symmetric about 0.5, with 3 columns (0.3%) at /// each of 0 and 1: the 0.1% points are 0 and 1, the median 0.5. - @Test(.disabled("waits on TP-6")) func rampIsIdentity() throws { + @Test func rampIsIdentity() throws { var ramp = try PixelBuffer(width: 1024, height: 4) for y in 0..<4 { for x in 0..<1024 { @@ -44,7 +43,7 @@ import Testing Self.expectIdentity(try AutoTone.suggest(for: ramp), "ramp") } - @Test(.disabled("waits on TP-6")) func blackSuggestsIdentity() throws { + @Test func blackSuggestsIdentity() throws { let black = try PixelBuffer(width: 32, height: 32, fill: RGBA(r: 0, g: 0, b: 0, a: 1)) Self.expectIdentity(try AutoTone.suggest(for: black), "black") Self.expectIdentity(try AutoTone.suggest(for: PixelBuffer(width: 32, height: 32)), "transparent") diff --git a/Tests/CoreRasterTests/ToneStubTests.swift b/Tests/CoreRasterTests/ToneStubTests.swift index 2e284070..e8f502c3 100644 --- a/Tests/CoreRasterTests/ToneStubTests.swift +++ b/Tests/CoreRasterTests/ToneStubTests.swift @@ -1,20 +1,9 @@ -// ToneStubTests.swift: the tone-pack contract stub (TP-0) as it stands -// before TP-6 fills it. `AutoTone.suggest` throws -// `RasterError.invalid("not built")` and `AutoTone` holds the five values it -// is given. TP-6 deletes the rows here that its op outgrows. +// ToneStubTests.swift: what is left of the tone-pack contract stub (TP-0) +// once TP-6 filled `AutoTone.suggest` (its checks are `AutoToneTests`): +// `AutoTone` holds the five values it is given. import CoreRaster import Testing -private let notBuilt = RasterError.invalid("not built") - -private func sample() throws -> PixelBuffer { - try PixelBuffer(width: 2, height: 2, fill: RGBA(r: 0.2, g: 0.4, b: 0.6, a: 1)) -} - -@Test func autoToneStubThrowsNotBuilt() throws { - #expect(throws: notBuilt) { try AutoTone.suggest(for: try sample()) } -} - @Test func autoToneHoldsItsValues() { let tone = AutoTone(inBlack: 0.1, inWhite: 0.9, gamma: 1.2, temperature: -5, tint: 3) #expect(tone.inBlack == 0.1) diff --git a/Tests/mutations/CoreRaster.AutoTone.json b/Tests/mutations/CoreRaster.AutoTone.json new file mode 100644 index 00000000..2752c589 --- /dev/null +++ b/Tests/mutations/CoreRaster.AutoTone.json @@ -0,0 +1,16 @@ +[ + { + "note": "AutoTone clips 1% at each end of the luma histogram instead of 0.1%, so a clean ramp gets a black point above 0, caught by AutoToneTests.rampIsIdentity", + "file": "Sources/CoreRaster/Tone/AutoTone.swift", + "anchor": "static let clip = 0.001", + "replacement": "static let clip = 0.01", + "filter": "AutoToneTests" + }, + { + "note": "AutoTone divides by an empty bright set, so an all-black buffer's white balance is NaN, caught by AutoToneTests.blackSuggestsIdentity", + "file": "Sources/CoreRaster/Tone/AutoTone.swift", + "anchor": "guard count > 0 else { return (0, 0) }", + "replacement": "guard count >= 0 else { return (0, 0) }", + "filter": "AutoToneTests" + } +]