Keep the photo's colour when the highlight and shadow sliders move

Highlight/Shadow scaled R, G and B by one luma gain. That keeps the ratio
but crushes absolute chroma, so -SH turned a saturated blue into near-black
and -HL turned bright colours grey, and both sliders only bit at the very
ends of the range (knees 0.80..1.00 / 0.00..0.30) — they read as dead on
any photo without true whites or blacks.

The curve now moves the luma and carries the colour difference (rgb - luma)
along at clamp(gain, 0.55, 1.35), so hue survives darkening and lifting
instead of collapsing to black or white. Both knobs are pure additive luma
shifts with soft knees over the upper/lower half: the 0.50 midpoint moves
under 3%, and the curve stays monotonic (the old multiplicative form was
not — with hl=-1 a grey 0.73 came out darker than 0.80).

Kotlin applyTone port and the native bench probe gates follow.

Verified on device (4200x2800 probe: grey ramp + colour patches), library
export at ratio 4:3: -10 highlight leaves the darks alone and drops white
243->189 with the sky still blue; -10 shadow keeps blue as dark blue
(0,0,254)->(0,3,146), never black, ramp stays monotonic. Export keeps the
source 4200x2800 as well, so nothing is cropped.
This commit is contained in:
2026-09-10 11:41:43 +07:00
parent 4ad26b514e
commit bba2d76957
3 changed files with 41 additions and 53 deletions
@@ -485,29 +485,22 @@ private fun applyTone(bmp: Bitmap, dr: Float, hl: Float, sh: Float) {
val g = ((c shr 8) and 0xFF) / 255f
val b = (c and 0xFF) / 255f
val t = (0.2126f * r + 0.7152f * g + 0.0722f * b).coerceIn(0f, 1f)
val hlMask = smoothstep(0.80f, 1.00f, t)
val shMask = 1f - smoothstep(0.00f, 0.30f, t)
var o = t
// Highlight: bright end only, multiplicative on both signs (symmetric region).
if (hl >= 0f) {
o *= 1f + hl * 0.30f * hlMask
} else {
o *= 1f + hl * 0.45f * hlMask
}
// Shadow: dark end only.
if (sh >= 0f) {
o += sh * 0.80f * shMask * (1f - t)
} else {
o *= 1f + sh * 0.80f * shMask
}
// Wide, soft knees (see TONE_SKSL): HL rides the upper half, SH the lower.
val hlMask = smoothstep(0.45f, 1.00f, t)
val shMask = 1f - smoothstep(0.00f, 0.55f, t)
// Pure additive luma shifts -> monotonic curve (no reversal on a ramp).
var o = t + hl * 0.32f * hlMask + sh * 0.34f * shMask
// Dynamic range: gentle shadow lift + highlight roll (protect brights).
o += dr * 0.12f * shMask * (1f - t)
o -= dr * 0.18f * hlMask * t
val f = (o / max(t, 0.0004f)).coerceIn(0f, 4f)
o = o.coerceIn(0f, 1f)
// Lightness takes the curve, hue stays: the colour difference is gained
// part-way so darkening cannot collapse a saturated colour to black.
val cg = (o / max(t, 0.0004f)).coerceIn(0.55f, 1.35f)
val alpha = (c ushr 24) and 0xFF
val nr = ((r * f).coerceIn(0f, 1f) * 255f).roundToInt()
val ng = ((g * f).coerceIn(0f, 1f) * 255f).roundToInt()
val nb = ((b * f).coerceIn(0f, 1f) * 255f).roundToInt()
val nr = ((o + (r - t) * cg).coerceIn(0f, 1f) * 255f).roundToInt()
val ng = ((o + (g - t) * cg).coerceIn(0f, 1f) * 255f).roundToInt()
val nb = ((o + (b - t) * cg).coerceIn(0f, 1f) * 255f).roundToInt()
px[i] = (alpha shl 24) or (nr shl 16) or (ng shl 8) or nb
}
bmp.setPixels(px, 0, w, 0, 0, w, h)
+2 -2
View File
@@ -53,8 +53,8 @@ export async function nativeBenchProbe(): Promise<void> {
console.error('[BENCH] classic-neg-on-black matrix[0..4]=' + matrix.slice(0, 5).join(','));
// P2 tone parity on solid ramps (expected values computed from TONE_SKSL):
// white(255) hl=-1 -> 140; gray64 sh=+1 -> 75; white dr=1 -> 209;
// gray26 sh=+1 -> 104; gray26 hl=+1 sh=-1 -> 11.
// white(255) hl=-1 -> 173; gray64 sh=+1 -> 113; white dr=1 -> 209;
// gray26 sh=+1 -> 105; gray26 hl=+1 sh=-1 -> 0.
const toneGates: Array<{ rgb: [number, number, number]; tone: { dr: number; hl: number; sh: number }; tag: string }> = [
{ rgb: [255, 255, 255], tone: { dr: 0, hl: -1, sh: 0 }, tag: 'white-hl-1' },
{ rgb: [64, 64, 64], tone: { dr: 0, hl: 0, sh: 1 }, tag: 'gray64-sh+1' },
+27 -32
View File
@@ -1,22 +1,24 @@
import { ColorAdjustments } from '../types';
// Tone-domain adjustments (Fuji-style Dynamic Range + Highlight/Shadow).
// SkSL runtime effect over a child image shader, driven by luma so hue is
// preserved (single multiplicative factor per pixel).
// SkSL runtime effect over a child image shader.
//
// Soft-knee masks keep the knobs independent, like camera tone curves:
// hl affects ONLY the bright end (knee 0.80..1.00),
// sh affects ONLY the dark end (knee 0.00..0.30),
// leaving the whole 0.30..0.80 mid/upper band untouched. Earlier knees
// (0.40..0.95 / 0.05..0.55, then 0.55..1.0 / 0.0..0.45, then 0.70..1.0) still
// reached mid/upper grays, so HL read as a global gray/brightness change
// instead of isolating the bright end — on-device testing confirmed it.
// Lightness/chroma split: the curve moves the luma and the colour difference
// (rgb - luma) carries the hue through with most of its chroma. Scaling R,G,B
// by one gain keeps the *ratio* but crushes absolute chroma — that is what
// turned saturated blues black under -SH and bright colours grey under -HL.
//
// HL is multiplicative on BOTH signs so +/− act on the same region:
// + scales 0.85+ up toward white (natural clip at 1.0), − scales them down.
// A + additive lift using (1-t) dies at t→1.0 and never touched the specular
// end — that asymmetry made +HL look like a mid-tone push, not a highlight
// change. (SH keeps its original form; additive + worked there.)
// Wide, soft knees so the knobs reach like a tone curve instead of biting only
// at the very ends: HL rides the upper half (0.45..1.00), SH the lower half
// (0.00..0.55), and the 0.50 midpoint moves less than 3%. Earlier narrow
// knees (0.80..1.00 / 0.00..0.30) made both sliders look dead on any photo
// without true whites or blacks.
//
// Both knobs are pure additive shifts of the luma. That keeps the curve
// monotonic (steepest knee slope 2.73/unit * 0.32 = 0.87, +SH overlap still
// positive), so a brighter input can never come out darker. The earlier
// multiplicative form was NOT monotonic: with hl=-1 a grey 0.73 came out
// darker than 0.80.
//
// dr - DR strength 0..1: lifts shadows slightly and rolls highlights
// (Fuji extended DR); 0/auto/DR100 = no extra curve.
@@ -34,28 +36,21 @@ uniform float sh;
uniform float vib;
vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
float t = clamp(dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0);
float hlMask = smoothstep(0.80, 1.0, t);
float shMask = 1.0 - smoothstep(0.0, 0.30, t);
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
float t = clamp(dot(rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0);
float hlMask = smoothstep(0.45, 1.00, t);
float shMask = 1.0 - smoothstep(0.00, 0.55, t);
// NOTE: never name a local 'out' — it is a reserved SkSL qualifier.
float o = t;
// Highlight: bright end only, multiplicative on both signs (symmetric region).
if (hl >= 0.0) {
o *= 1.0 + hl * 0.30 * hlMask;
} else {
o *= 1.0 + hl * 0.45 * hlMask;
}
// Shadow: dark end only.
if (sh >= 0.0) {
o += sh * 0.80 * shMask * (1.0 - t);
} else {
o *= 1.0 + sh * 0.80 * shMask;
}
float o = t + hl * 0.32 * hlMask + sh * 0.34 * shMask;
// Dynamic range: gentle shadow lift + highlight roll (protect brights).
o += dr * 0.12 * shMask * (1.0 - t);
o -= dr * 0.18 * hlMask * t;
float f = clamp(o / max(t, 0.0004), 0.0, 4.0);
vec3 rgb = clamp(c.rgb * f, 0.0, 1.0);
o = clamp(o, 0.0, 1.0);
// Lightness takes the curve, hue stays: the colour difference is gained
// only part-way so darkening cannot collapse a colour to black and lifting
// cannot blow a dark saturated colour out to white.
float cg = clamp(o / max(t, 0.0004), 0.55, 1.35);
rgb = clamp(vec3(o) + (rgb - vec3(t)) * cg, 0.0, 1.0);
// Vibrance: push the LESS-saturated pixels harder than the vivid ones.
float l2 = dot(rgb, vec3(0.2126, 0.7152, 0.0722));
float mx = max(max(rgb.r, rgb.g), rgb.b);