Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions Sources/CoreRaster/Tone/ColorAdjust.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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)
Expand All @@ -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),
Expand Down
5 changes: 3 additions & 2 deletions Sources/CoreRaster/W1/W1Values.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
88 changes: 78 additions & 10 deletions Tests/CoreRasterTests/ColorAdjustTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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)] {
Expand All @@ -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<Double>)] = [
(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)")
}
}

Expand All @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions Tests/mutations/CoreRaster.ColorAdjust.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
Loading
Loading