diff --git a/Sources/CoreRaster/Tone/ColorAdjust.swift b/Sources/CoreRaster/Tone/ColorAdjust.swift index bf2ad61f..5673d03a 100644 --- a/Sources/CoreRaster/Tone/ColorAdjust.swift +++ b/Sources/CoreRaster/Tone/ColorAdjust.swift @@ -25,6 +25,7 @@ extension PixelBuffer { /// encoded hue a colour picker shows), and capped so /// no channel leaves 0...1 (or its own range when already outside); /// a negative amount desaturates every pixel evenly (−100 is grey at luma). + /// Throws `RasterError.invalid` when `amount` is non-finite or outside its range. public func vibrance(_ amount: Float) throws -> PixelBuffer { try ColorMath.check("vibrance", amount, limit: 100) if amount == 0 { return self } @@ -54,6 +55,7 @@ extension PixelBuffer { /// -100...100, as standard HSL on the encoded values: the hue rotates, /// saturation scales by 1 + saturation/100 (clamped to 0...1), and /// lightness blends toward white (positive) or black (negative). + /// Throws `RasterError.invalid` when a value is non-finite or outside its range. public func hueSaturation(hue: Float, saturation: Float, lightness: Float) throws -> PixelBuffer { try ColorMath.check("hue", hue, limit: 180) try ColorMath.check("saturation", saturation, limit: 100) @@ -63,19 +65,22 @@ extension PixelBuffer { return try mappingStraight { ColorMath.shifted($0, by: shift, from: ColorMath.hsl(of: $0)) } } + /// An `HSLShift`'s `hue` is percent of a ±30° turn (100 = +30°), not + /// degrees as in `hueSaturation`; its saturation and lightness are percent. /// Per-hue-range HSL shifts over eight ranges centred at 0, 30, 60, 120, /// 180, 240, 270 and 300 degrees. A pixel's weight per range is a raised /// cosine from the range's centre to each neighbouring centre, so the /// weights sum to 1; the shifts are summed by weight and applied as in - /// `hueSaturation`, a range's hue 100 being 30 degrees. Each component is - /// clamped to -100...100; a non-finite one throws. A grey pixel (no hue) - /// and one whose summed shift is zero pass through untouched. + /// `hueSaturation`. Out-of-range values are clamped to -100...100; a + /// non-finite one throws `RasterError.invalid` naming its band. A grey + /// pixel (no hue) and one whose summed shift is zero pass through untouched. public func hsl(_ adjustment: HSLAdjustment) throws -> PixelBuffer { - let ranges = [adjustment.reds, adjustment.oranges, adjustment.yellows, adjustment.greens, - adjustment.aquas, adjustment.blues, adjustment.purples, adjustment.magentas] - let shifts = try ranges.map { r -> ColorMath.Shift in + let bands = [("reds", adjustment.reds), ("oranges", adjustment.oranges), ("yellows", adjustment.yellows), + ("greens", adjustment.greens), ("aquas", adjustment.aquas), ("blues", adjustment.blues), + ("purples", adjustment.purples), ("magentas", adjustment.magentas)] + let shifts = try bands.map { band, r -> ColorMath.Shift in for (name, v) in [("hue", r.hue), ("saturation", r.saturation), ("lightness", r.lightness)] { - guard v.isFinite else { throw RasterError.invalid("hsl \(name) \(v): must be finite") } + guard v.isFinite else { throw RasterError.invalid("hsl \(band) \(name) \(v): must be finite") } } func clamped(_ v: Float) -> Double { Double(min(max(v, -100), 100)) } return ColorMath.Shift(hue: clamped(r.hue) * 0.3, saturation: clamped(r.saturation), diff --git a/Sources/CoreRaster/W1/W1Values.swift b/Sources/CoreRaster/W1/W1Values.swift index a19d5597..e62b1a09 100644 --- a/Sources/CoreRaster/W1/W1Values.swift +++ b/Sources/CoreRaster/W1/W1Values.swift @@ -54,8 +54,9 @@ public enum CanvasAnchor: Hashable, Sendable { /// What a mask reads from a source buffer: its alpha, or its luminance. public enum MatteSource: Hashable, Sendable { case alpha, luminance } -/// One hue band's shift. Each component is in -100...100; 0 changes nothing. -/// The tone ops clamp a value outside that range. +/// One hue band's shift. `hue` is percent of a ±30° turn (100 = +30°), not +/// degrees; saturation and lightness are percent. Each component is in +/// -100...100; 0 changes nothing. The tone ops clamp a value outside that range. public struct HSLShift: Hashable, Sendable { public var hue, saturation, lightness: Float diff --git a/Tests/CoreRasterTests/ColorAdjustTests.swift b/Tests/CoreRasterTests/ColorAdjustTests.swift index 8df6f926..e7ebaf8c 100644 --- a/Tests/CoreRasterTests/ColorAdjustTests.swift +++ b/Tests/CoreRasterTests/ColorAdjustTests.swift @@ -81,9 +81,10 @@ enum ToneCheck { } @Suite struct ColorAdjustTests { - /// Hue/saturation as TP-3 defines it (lead note on #432): standard HSL on - /// the encoded values; hue rotates, saturation scales by 1 + s/100, and - /// lightness blends toward white (positive) or black (negative). + /// Hue/saturation as TP-3 defines it (lead note on #432): the standard HSL + /// rotate/scale/blend on the encoded values; hue rotates, saturation scales + /// by 1 + s/100, and lightness blends toward white (positive) or black + /// (negative). `hueSaturationHandValues` pins it to hand-computed pixels. static func hueSaturationReference(_ p: RGBA, hue: Double, saturation: Double, lightness: Double) -> RGBA { guard p.a > 0 else { return p } var (h, s, l) = ToneCheck.hsl(ToneCheck.encoded(p)) @@ -124,7 +125,7 @@ enum ToneCheck { #expect(abs(boostSkin / boostOther - 0.5) <= 0.05) } - @Test func hueSaturationMatchesReference() throws { + @Test func hueSaturationMatchesHSLReference() throws { let b = try ToneCheck.seeded(width: 40, height: 25, seed: 0xA2) let input = try ToneCheck.pixels(b) for (h, s, l) in [(30.0, 25.0, -10.0), (-120, -60, 40), (170, 100, 0)] { @@ -146,13 +147,67 @@ enum ToneCheck { } } - /// Every range at -100 saturation greys every hue: the weights sum to 1. + /// Every range at lightness -50 halves every saturated pixel's HSL + /// lightness, exactly as one range alone would: the weights sum to 1 + /// (a sum above 1 darkens more, below 1 less). Greys pass through. @Test func rangeWeightsSumToOne() throws { - let grey = HSLShift(saturation: -100) - let all = HSLAdjustment(reds: grey, oranges: grey, yellows: grey, greens: grey, aquas: grey, blues: grey, - purples: grey, magentas: grey) - for p in try ToneCheck.pixels(ToneCheck.seeded(width: 32, height: 16, seed: 0xA3, opaque: true).hsl(all)) { - #expect(ToneCheck.spread(ToneCheck.encoded(p)) <= 1e-5, "\(p)") + let dark = HSLShift(lightness: -50) + let all = HSLAdjustment(reds: dark, oranges: dark, yellows: dark, greens: dark, aquas: dark, blues: dark, + purples: dark, magentas: dark) + let b = try ToneCheck.seeded(width: 32, height: 16, seed: 0xA3, opaque: true) + let input = try ToneCheck.pixels(b) + let single = try ToneCheck.pixels(b.hueSaturation(hue: 0, saturation: 0, lightness: -50)) + for (i, q) in try ToneCheck.pixels(b.hsl(all)).enumerated() { + let grey = ToneCheck.spread(ToneCheck.encoded(input[i])) == 0 + #expect(ToneCheck.gap(q, grey ? input[i] : single[i]) <= 1e-5, "\(input[i])") + } + } + + /// Hand-computed pixels, independent of the formula's code: hue +120 turns + /// pure red pure green; hue leaves mid grey alone; saturation -100 greys red + /// at its HSL lightness 0.5; lightness +-50 blends red toward white or black. + @Test func hueSaturationHandValues() throws { + let red = ToneCheck.pixel([1, 0, 0]), grey = ToneCheck.pixel([0.5, 0.5, 0.5]) + let cases: [(RGBA, Float, Float, Float, SIMD3)] = [ + (red, 120, 0, 0, [0, 1, 0]), (grey, 90, 0, 0, [0.5, 0.5, 0.5]), (red, 0, -100, 0, [0.5, 0.5, 0.5]), + (red, 0, 0, 50, [1, 0.5, 0.5]), (red, 0, 0, -50, [0.5, 0, 0]), (red, -60, 0, 0, [1, 0, 1]), + ] + for (p, h, s, l, want) in cases { + let out = try ToneCheck.pixels(ToneCheck.row([p]).hueSaturation(hue: h, saturation: s, lightness: l)) + #expect(ToneCheck.gap(ToneCheck.encoded(out[0]), want) <= 1e-5, "\(p) at \(h), \(s), \(l)") + } + } + + /// An `HSLShift` hue of 100 is a 30 degree turn, the same as `hueSaturation(hue: 30)`. + @Test func hslHueIsPercentOfThirtyDegrees() throws { + let red = try ToneCheck.row([ToneFixtures.pureRed.pixel]) + let ranged = try ToneCheck.pixels(red.hsl(HSLAdjustment(reds: HSLShift(hue: 100)))) + let turned = try ToneCheck.pixels(red.hueSaturation(hue: 30, saturation: 0, lightness: 0)) + #expect(ToneCheck.gap(ranged[0], turned[0]) <= 1e-5) + } + + /// Reds hue 100 turns pure red to hue 30; reds lightness 50 lifts pure red + /// to HSL lightness 0.75 and leaves a mid grey (no hue) alone. + @Test func rangeHueAndLightness() throws { + let red = ToneFixtures.pureRed.pixel, grey = ToneCheck.pixel([0.5, 0.5, 0.5]) + let turned = try ToneCheck.pixels(ToneCheck.row([red]).hsl(HSLAdjustment(reds: HSLShift(hue: 100)))) + #expect(abs(ToneCheck.hsl(ToneCheck.encoded(turned[0])).h - 30) <= 1e-4) + let lifted = try ToneCheck.pixels(ToneCheck.row([red, grey]).hsl(HSLAdjustment(reds: HSLShift(lightness: 50)))) + #expect(abs(ToneCheck.hsl(ToneCheck.encoded(lifted[0])).l - 0.75) <= 1e-4) + #expect(lifted[1] == grey) + } + + /// vibrance -100 is grey at linear luma; vibrance 100 keeps every in-gamut + /// straight channel in 0...1. + @Test func vibranceNegativeAndCapped() throws { + let b = try ToneCheck.seeded(width: 32, height: 16, seed: 0xA6, opaque: true) + for p in try ToneCheck.pixels(b.vibrance(-100)) { + let y = 0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b + #expect(max(abs(p.r - y), abs(p.g - y), abs(p.b - y)) <= 1e-6, "\(p)") + } + for p in try ToneCheck.pixels(b.vibrance(100)) { + let s = p.unpremultiplied() + #expect([s.r, s.g, s.b].allSatisfy { (-1e-6...(1 + 1e-6)).contains($0) }, "\(s)") } } @@ -169,6 +224,19 @@ enum ToneCheck { #expect(throws: RasterError.self) { try b.hueSaturation(hue: 0, saturation: .nan, lightness: 0) } #expect(throws: RasterError.self) { try b.hueSaturation(hue: 0, saturation: 0, lightness: .nan) } #expect(throws: RasterError.self) { try b.hsl(HSLAdjustment(greens: HSLShift(hue: .nan))) } + let error = #expect(throws: RasterError.self) { try b.hsl(HSLAdjustment(blues: HSLShift(hue: .nan))) } + guard case .invalid(let why) = error else { return } + #expect(why.contains("blues"), "\(why)") + } + + /// The documented policy: vibrance and hueSaturation throw out of range, + /// hsl clamps (reds saturation 150 acts as 100). + @Test func outOfRangePolicyIsDocumented() throws { + let b = try ToneCheck.seeded(width: 4, height: 4, seed: 0xA7, opaque: true) + #expect(throws: RasterError.self) { try b.vibrance(101) } + #expect(throws: RasterError.self) { try b.hueSaturation(hue: 181, saturation: 0, lightness: 0) } + let over = try b.hsl(HSLAdjustment(reds: HSLShift(saturation: 150))) + #expect(over == (try b.hsl(HSLAdjustment(reds: HSLShift(saturation: 100))))) } /// Out-of-range sliders throw; an `HSLShift` component is clamped to -100...100. diff --git a/Tests/mutations/CoreRaster.ColorAdjust.json b/Tests/mutations/CoreRaster.ColorAdjust.json index ddc41bb1..4b4f5dd4 100644 --- a/Tests/mutations/CoreRaster.ColorAdjust.json +++ b/Tests/mutations/CoreRaster.ColorAdjust.json @@ -19,5 +19,40 @@ "anchor": "let span = d < 0 ? lower[i] : upper[i]", "replacement": "let span = upper[i]", "filter": "ColorAdjustTests" + }, + { + "note": "hsl range hue taken as degrees instead of percent of a 30 degree turn, caught by ColorAdjustTests.hslHueIsPercentOfThirtyDegrees", + "file": "Sources/CoreRaster/Tone/ColorAdjust.swift", + "anchor": "hue: clamped(r.hue) * 0.3,", + "replacement": "hue: clamped(r.hue),", + "filter": "ColorAdjustTests" + }, + { + "note": "hsl range lightness dropped, caught by ColorAdjustTests.rangeHueAndLightness", + "file": "Sources/CoreRaster/Tone/ColorAdjust.swift", + "anchor": "lightness: clamped(r.lightness))", + "replacement": "lightness: 0)", + "filter": "ColorAdjustTests" + }, + { + "note": "hsl grey pass-through guard removed, so greys take the reds shift, caught by ColorAdjustTests.rangeHueAndLightness", + "file": "Sources/CoreRaster/Tone/ColorAdjust.swift", + "anchor": "guard hsl.s > 0 else { return p }", + "replacement": "", + "filter": "ColorAdjustTests" + }, + { + "note": "vibrance negative branch flipped, so -100 doubles chroma, caught by ColorAdjustTests.vibranceNegativeAndCapped", + "file": "Sources/CoreRaster/Tone/ColorAdjust.swift", + "anchor": "var scale = 1 + a", + "replacement": "var scale = 1 - a", + "filter": "ColorAdjustTests" + }, + { + "note": "vibrance gamut cap toward black removed, so a channel goes below 0, caught by ColorAdjustTests.vibranceNegativeAndCapped", + "file": "Sources/CoreRaster/Tone/ColorAdjust.swift", + "anchor": "if lo < y { cap = min(cap, y / (y - lo)) }", + "replacement": "", + "filter": "ColorAdjustTests" } ] diff --git a/docs/blueprints/core/features/tone-pack.md b/docs/blueprints/core/features/tone-pack.md index 4ffb20e7..0cea9c95 100644 --- a/docs/blueprints/core/features/tone-pack.md +++ b/docs/blueprints/core/features/tone-pack.md @@ -24,9 +24,9 @@ EC-b: `CPUBackend+Color.swift` and `Samples+Color.swift`). **C4:** container `coreraster`, component `image-ops` (`Sources/CoreRaster/Tone/`); container `corerender`, components `contract` (`ToneAdjustment`'s colour cases, EC's), `cpu-backend` (`CPUBackend+Color.swift`) and `parity` -(`Samples+Color.swift`). ⚠ Photo's `HueSaturation.swift` moves here (TP-3 -keeps its output); Photo deletes its copy in its W3 adoption. No new -component. +(`Samples+Color.swift`). ⚠ Photo's `HueSaturation.swift` is replaced by +TP-3's `hueSaturation`; pixel parity with it is checked when Photo adopts it +and deletes its copy in W3. No new component. **Clean-room.** Every formula is stated here from public colour science (Rec. 709 luma, HSL hue sectors, trilinear interpolation, the published @@ -104,9 +104,14 @@ public struct AutoTone: Hashable, Sendable { public var inBlack: Float, inWhite: - **vibrance.** Saturation boost weighted by `1 − s` (low-saturation pixels move most) and damped for skin hues (hue 15°–50°) by half; negative amounts desaturate evenly. -- **hueSaturation.** The formula in Photo's `HueSaturation.swift` at - creative-apps `origin/dev` (this suite's code), so the adoption is a swap - with equal pixels. +- **hueSaturation.** The standard HSL rotate/scale/blend on the encoded + values, as built: hue rotates in degrees, saturation scales by + 1 + saturation/100, lightness blends toward white or black (round 1 lead + decision on #432: the app repo is outside this session). Pixel parity with + Photo's existing `HueSaturation.swift` is checked when Photo adopts it (W3). +- **HSLShift units.** An `HSLShift`'s `hue` is percent of a ±30° turn + (100 = +30°), not degrees; saturation and lightness are percent. `vibrance` + and `hueSaturation` throw out of range; `hsl` clamps. - **hsl.** Eight ranges centred at 0, 30, 60, 120, 180, 240, 270, 300 degrees; a pixel's weight per range is a raised cosine over the neighbouring centres, so weights sum to 1; shifts are summed by weight. @@ -132,7 +137,7 @@ public struct AutoTone: Hashable, Sendable { public var inBlack: Float, inWhite: |---|---|---|---|---|---| | TP-1 | feature issue | Checks first: the six suites disabled by name | `Tests/CoreRasterTests/ColorAdjustTests.swift`, `Tests/CoreRasterTests/ColorMixTests.swift`, `Tests/CoreRasterTests/ColorMapTests.swift`, `Tests/CoreRasterTests/ColorLUTTests.swift`, `Tests/CoreRasterTests/AutoToneTests.swift`, `Tests/CoreRenderTests/CPUColorTests.swift` | engine-contracts:EC-a | ~380, 6 | | TP-2 | – | Reference colours (hand-computed expected values per op), `.cube` fixtures (identity 17³, a warm look 33³, five damaged files), the cast photo | `Tests/CoreRasterTests/ToneFixtures.swift`, `Tests/CoreRasterTests/Fixtures/luts/` | engine-contracts:EC-a | ~200, 1 + fixtures | -| TP-3 | – | `vibrance`, `hueSaturation` (Photo's formula), `hsl`; turns on `ColorAdjustTests` | `Sources/CoreRaster/Tone/ColorAdjust.swift`, `Tests/CoreRasterTests/ColorAdjustTests.swift`, `Tests/mutations/CoreRaster.ColorAdjust.json` | TP-1, TP-2 | ~340, 3 | +| TP-3 | – | `vibrance`, `hueSaturation` (standard HSL formula), `hsl`; turns on `ColorAdjustTests` | `Sources/CoreRaster/Tone/ColorAdjust.swift`, `Tests/CoreRasterTests/ColorAdjustTests.swift`, `Tests/mutations/CoreRaster.ColorAdjust.json` | TP-1, TP-2 | ~340, 3 | | TP-4 | – | `blackAndWhite`, `channelMixer`, `colorBalance`; turns on `ColorMixTests` | `Sources/CoreRaster/Tone/ColorMix.swift`, `Tests/CoreRasterTests/ColorMixTests.swift`, `Tests/mutations/CoreRaster.ColorMix.json` | TP-1, TP-2 | ~300, 3 | | TP-5 | – | `gradientMap`, LUT apply, `CubeLUT.parse` with its fuzz test, `posterized`, `thresholded`, `inverted`; turns on `ColorMapTests`, `ColorLUTTests` | `Sources/CoreRaster/Tone/ColorMap.swift`, `Sources/CoreRaster/Tone/CubeLUT.swift`, `Tests/CoreRasterTests/ColorMapTests.swift`, `Tests/CoreRasterTests/ColorLUTTests.swift`, `Tests/mutations/CoreRaster.ColorMap.json`, `Tests/mutations/CoreRaster.CubeLUT.json` | TP-1, TP-2 | ~380, 6 | | TP-6 | – | `AutoTone.suggest`; turns on `AutoToneTests` | `Sources/CoreRaster/Tone/AutoTone.swift`, `Tests/CoreRasterTests/AutoToneTests.swift`, `Tests/mutations/CoreRaster.AutoTone.json` | TP-1, TP-2 | ~200, 3 | @@ -160,7 +165,7 @@ walk reads them, TP-10 is the prose task. |---|---|---|---| | TP-1 | six suites compile, disabled | – | – | | TP-2 | every fixture parses or fails as labelled | – | – | -| TP-3 | `ColorAdjustTests`: every op at neutral is `==` identity; vibrance 100 moves a 10%-saturated pixel more than a 90% one and a skin tone half as much; `hueSaturation` matches Photo's formula on 1,000 seeded pixels within 1e-6; `hsl` blues −100 saturation greys a pure blue and leaves a pure red; alpha and alpha-0 pixels unchanged | a range bleeds or alpha moves | `CoreRaster.ColorAdjust`: vibrance weight `s` for `1 − s` → `ColorAdjustTests.lowSaturationMovesMost`; skin damping dropped → `.skinMovesHalf`; HSL weights don't sum to 1 (cosine over one neighbour) → `.rangeWeightsSumToOne` | +| TP-3 | `ColorAdjustTests`: every op at neutral is `==` identity; vibrance 100 moves a 10%-saturated pixel more than a 90% one and a skin tone half as much; `hueSaturationMatchesHSLReference` is a reference check of the standard HSL rotate/scale/blend on 1,000 seeded pixels within 1e-6, and `hueSaturationHandValues` pins it to hand-computed pixels; `hsl` blues −100 saturation greys a pure blue and leaves a pure red; alpha and alpha-0 pixels unchanged | a range bleeds or alpha moves | `CoreRaster.ColorAdjust`: vibrance weight `s` for `1 − s` → `ColorAdjustTests.lowSaturationMovesMost`; skin damping dropped → `.skinMovesHalf`; HSL weights don't sum to 1 (cosine over one neighbour) → `.rangeWeightsSumToOne`; range hue ×1 for ×0.3 → `.hslHueIsPercentOfThirtyDegrees`; range lightness or grey guard dropped → `.rangeHueAndLightness`; vibrance negative branch or gamut cap → `.vibranceNegativeAndCapped` | | TP-4 | `ColorMixTests`: default B&W of pure red is 0.40 luma-normalised; an identity matrix is identity; `monochrome` gives r == g == b; colour balance +shadows red leaves a white pixel; `preserveLuminosity` keeps luma within 1e-5 | a weight is read from the wrong channel | `CoreRaster.ColorMix`: weights indexed by channel not hue range → `ColorMixTests.redWeightIsForty`; preserve skipped → `.preserveKeepsLuma`; zone smoothstep edges swapped → `.whiteUnmovedByShadows` | | TP-5 | `ColorMapTests`: a black-to-white map is the luma; posterize 2 gives only 0 and 1; threshold 0.5 splits at luma 0.5; `inverted()` twice is `==`; `ColorLUTTests`: identity LUT within 1e-5; the warm look matches hand values at 8 lattice points exactly and at 8 midpoints within 1e-6; each damaged file throws (size 1, 66, short table, NaN, DOMAIN 0…2); 500 seeded mutated files throw or parse, never crash | a lattice point is off or a damaged file loads | `CoreRaster.ColorMap`: posterize rounds with `floor` → `ColorMapTests.posterizeTwoLevels`; threshold compares red not luma → `.thresholdOnLuma`. `CoreRaster.CubeLUT`: blue-fastest order → `ColorLUTTests.redFastest`; domain check skipped → `.refusesOtherDomain`; interpolation nearest → `.midpointsTrilinear` | | TP-6 | `AutoToneTests`: the cast photo's suggestion applied neutralises the grey patch within 2/255; a clean grey ramp suggests identity within 1e-3; an all-black buffer suggests identity, never NaN | a cast survives or a flat image gives NaN | `CoreRaster.AutoTone`: clip 1% for 0.1% → `AutoToneTests.rampIsIdentity`; division by an empty bright set → `.blackSuggestsIdentity` |