Classic: how hard can it be?
It doesn’t get simpler
Of Photoshop’s four interpolation methods, Classic looks like the harmless one.
What could possibly be hard about it? Take one RGB triplet and another, split each into channels and interpolate them independently. No conversions into linear RGB or Oklab, both of which we covered last time. An evening’s work, two at most.
Think we’re done?
This is the tip of the iceberg, and that’s where we’ll start. This article is about the curve Photoshop draws from one color to another.
Every channel on its own
Classic doesn’t work with color as a whole. It takes it apart into three numbers and processes each channel independently, knowing nothing about the other two.
For a transition from red (255, 0, 0) to blue (0, 0, 255) you get three
independent curves: 255 → 0 for red, a constant 0 for green, and 0 → 255
for blue. Then the three results are reassembled into a color.
Last time we called this interpolation in sRGB, for brevity. More precisely: Classic interpolates the document’s encoded RGB values directly, without converting them into any other color space. In an ordinary sRGB document those really are sRGB numbers, but the principle isn’t tied to sRGB specifically. The numbers enter the algorithm exactly as they left the color picker.
That decision has a price, and we’ve already seen it: last time the
black-to-white transition in Classic
came out darker than in Linear. The reason is the same puzzle involving 128,
which sits in the middle of the numeric scale but carries only about 21% of the
light. Classic falls into that trap precisely because it never leaves the
document’s numbers.
Stops and segments
A quick reminder of what a gradient is made of. There are stops: points where the color is set by hand. In a black-to-white gradient, for instance, the black and white endpoints are the stops.
Every stop has:
- a position within the gradient,
- an RGB color.
And between each pair of adjacent stops there’s a midpoint, the center of the
transition, which we covered in
“Midpoint as center of gravity”
last time. Position and color describe the stop itself, while the midpoint
describes the transition between two stops. So n stops come with n−1
midpoints. Those gaps between adjacent stops are what we’ll call segments.
There are n−1 of them too:
Each segment is computed separately from the rest, and the first step is to convert the pixel’s position from the coordinates of the whole gradient to the coordinates of the segment:
Where:
x: the pixel’s position in the whole gradientpᵢ: the position of the segment’s left stoppᵢ₊₁: the position of the right stopu ∈ [0,1]: the normalized position of the pixel inside the segment
If x falls outside the outermost stops, there’s no segment to look for. To the
left of the first stop its color holds. To the right of the last one, the color
of the last stop holds.
Let’s do it with numbers. Say we’re drawing a horizontal gradient in a rectangle
measuring 200 × 50 pixels, with three stops: black at the very start, white at
60% of the length, and gray at the end. In pixels that’s 0, 120 and 200.
Take the position at pixel 150. In the whole gradient that’s three quarters
of the way: x = 150/200 = 0.75. But that’s not what interpolation cares about.
It cares where the point sits inside its own segment, and it lands in the
second one, running from the white stop to the gray. That segment’s bounds are
p₁ = 0.6 and p₂ = 1.
So: across the whole gradient the pixel has covered 75% of the distance, but within its segment only 37.5%. From here on the algorithm works with the second number.
This means that as far as a segment is concerned, it’s always a transition
from 0 to 1. How many pixels it actually occupies, and where in the gradient
it starts, no longer matters for interpolation.
Remapping the position through the midpoint
We covered the midpoint in detail last time, so here’s just a reminder of the formula:
It’s built so that φ(m, m) = 0.5: wherever the diamond slides off to, as far
as interpolation is concerned it always ends up exactly in the middle. We’ll
call the remapped position t. It’s t, not the original u, that goes on
from here.
Drag the diamond below the plot. The colors at the ends won't move and the points will stay put. The only thing that shifts is how fast the curve covers the first half of the way and how slowly it covers the second.
Why straight lines aren’t enough
The position is computed, the midpoint is accounted for. The main question is still open: along what path does the color travel from one stop to the next?
The obvious answer is a straight line. But if every segment were interpolated with a straight line, the gradient would change speed abruptly at every interior stop.
Here’s a gradient computed two ways. In the first, every segment is a straight line. In the second, the curve takes its neighbors into account, which is what we’re working toward in this section.
Look at the yellow stop: on the top strip there’s a small plateau around it, as if the color hesitates there. On the bottom one it’s gone. The transition runs evenly.
And that’s the whole difference. At its most visible it’s about 20 code values on a 0-to-255 scale, and the eye forgives kinks like that readily: the color changes smoothly, nothing looks off.
The kink becomes obvious the moment you stop looking at the strip and look at each channel’s curve instead:
Each channel approaches the middle stop along one straight line and leaves along another. At the junction all three break at an angle.
Same stops, but the corners are smoothed: each curve enters the middle stop and leaves it with the same slope.
For two neighboring curves to meet at a stop without a kink, they need to know what’s happening on both sides of it.
That’s the job of the tangent. We met it
last time:
it sets the speed and direction with which a curve enters a point and leaves it.
One detail matters here: that speed is measured on the segment’s own scale, with
the same t running from 0 to 1, not in the pixels of the whole gradient. If
the tangent is the same on the left and on the right of a stop, there’s no kink
on that scale. We’ll see shortly what happens when we return to pixels.
Classic computes such a tangent at every stop, separately for red, green and blue.
It is convenient to express this in terms of chords. A segment’s chord is how much the channel changed from its start to its end (that is, the difference between the channel’s value at the next stop and at the current one):
Where:
dᵢ: the chord of segmentiVᵢ: the channel’s value at the stop with indexi
Then the tangent at a stop is the average of the two chords meeting there:
A missing chord counts as zero: the first stop has no chord on its left, and the last one has none on its right. This is why the same formula works in all three cases:
- first stop:
T₀ = (0 + d₀)/2 = d₀/2, half the chord on the right - interior stop:
Tᵢ = (dᵢ₋₁ + dᵢ)/2 - last stop:
Tₙ₋₁ = (dₙ₋₂ + 0)/2 = dₙ₋₂/2, half the chord on the left
A stop does not affect its own tangent
There’s a subtlety about an interior stop worth spelling out.
Both chords include its value: the left one arrives at it, the right one departs from it. You’d think its own value is what sets the slope. But expand the chords and it disappears:
Vᵢ enters the left chord with a plus and the right one with a minus, and the
two cancel. What’s left is the difference between the neighbors, halved.
This is exactly the
Catmull-Rom tangent from last time.
Now we can see where it comes from.
The practical meaning: the slope of the curve at a stop is set by its neighbors, not by the stop itself. Move the middle stop up or down without touching the outer ones, and the curve will pass through a new point but keep its old tangent.
An example
Take the values of a single channel at three stops:
V₀ = 0, V₁ = 1, V₂ = 0.9Chords: d₀ = 1, d₁ = −0.1. The channel rises sharply, then dips slightly.
Tangents:
Note T₁. The second segment falls, yet the tangent at its start stayed
almost the same as on the first segment, still pointing up. The momentum from
the first segment hasn’t gone anywhere. This is exactly the discordance we
talked about in the
concordant, discordant and overshoot
section last time.
The short strokes at each point are the tangents. Drag a point up or down: the neighboring stop's tangent moves along with it, because it's computed from both neighbors at once.
The tangent at an interior stop is computed once and used by both neighboring curves:
one tangent Tᵢ serves both segments
From tangents to the curve
Now every segment has everything a curve needs: the values at its ends and the tangents at them. All that’s left is to join four numbers into one formula. This is the same cubic Hermite spline from last time, written directly in terms of its basis functions:
Where:
t ∈ [0,1]: the position after the midpoint remapV₀,V₁: the channel’s values at the segment’s endsT₀,T₁: the tangents at those stopsH(t): the value of the Hermite curve
The formula guarantees the curve passes exactly through both stops:
Everything between them is decided by the tangents.
A gradient with two stops
For the simplest 0 → 1 transition there’s a single chord, and both tangents
equal half of it:
Substitution: how the formula collapses
Let’s compare it with linear interpolation L(t)=t:
Both curves pass through (0, 0), (0.5, 0.5) and (1, 1), but between those
points the Hermite curve first moves more slowly, then faster, then slows down
again before the end. That’s what makes the transition look “soft”: the color
lingers near both stops and races through the middle.
When the curve runs off the edge
Back to the 0 → 1 → 0.9 example.
The second segment has to fall from 1 to 0.9 by just one tenth. But the
tangent at its start is 0.45 and points up: the curve enters it at full
upward velocity inherited from the first segment.
It has nowhere to go. It first climbs above 1 and only then turns back toward
0.9. Substituting V₀ = 1, V₁ = 0.9, T₀ = 0.45 and T₁ = −0.05:
Substitution: where this curve comes from
The maximum occurs at t ≈ 0.24 and equals 1.05. A channel that was never
supposed to rise above 1 overshoots by 0.05.
Even one such channel produces a color present in neither stop. Had this happened to the red channel in a transition from red to dark red, a band brighter than the source color would appear in the middle.
Classic doesn’t modify the tangent and doesn’t replace it with zero. It does something simpler: first it computes the ordinary Hermite curve, then it clamps the result to the values at the ends of the current segment.
Let’s define the lower and upper bounds:
And then:
If H(t) lies between Vmin and Vmax, nothing changes. If the curve went
above Vmax, the result becomes Vmax. If it dropped below Vmin, the result
becomes Vmin.
The highlighted corridor is the segment's range of values. The red fill is what the clamp cuts off. Drag the last point above the middle one: the discordance disappears, and there's nothing left to cut.
The plot shows that the original curve continues beyond the upper bound. The clamp merely keeps the result within the allowed range. As soon as the Hermite curve comes back inside it, the result starts following it again.
Why use the segment range rather than the RGB range?
At first glance it would be enough to clamp the result to [0,1]. The curve
isn’t going to leave the valid RGB range anyway. But the clamp here is
stricter.
For a 0.4 → 0.6 transition the value 0.7 is perfectly legal as far as sRGB
is concerned, yet it’s outside the range spanned by the segment’s endpoints.
Classic will clamp it down to 0.6.
Because of that, every channel moves only between its segment’s endpoint values,
even if its tangents try to drag the Hermite curve further. A useful consequence
is that a separate check for leaving [0,1] is no longer needed. If all the
stops are within the legal range, the result won’t leave it.
You might think the clamp is only needed on discordant segments, where the tangent and the chord point in different directions. But matching directions tell us only the sign of the tangent, not its magnitude.
Imagine a channel that first rose from 0 to 0.8, and on the next segment has
to climb just a little: from 0.8 to 0.81. The tangent at the shared stop
inherits the momentum of the first segment and stays positive, which makes it
concordant with the second. But compared to that segment’s tiny chord of 0.01
it’s enormous. The curve will shoot past 0.81, turn around and come back to the
final value. The clamp cuts that overshoot exactly as it would on a discordant
segment.
So what matters is not only the sign of the tangent but its magnitude relative to the chord. On well-behaved concordant segments the clamp does nothing. On overly steep ones it keeps the curve within the values of the stops.
Smoothness
The gradient panel has a Smoothness parameter. It’s reasonable to assume it smoothly blends the linear transition with the curved one: a straight line at zero, full Hermite at a hundred, something in between in the middle.
And it does. What’s interesting is something else: it isn’t curves that get blended.
A note before we go on: everything we’ve computed so far assumed Smoothness = 100%, the default. That’s why the tangents were taken as the plain average of two chords.
The slider acts earlier, between computing the tangents and building the curve, and pulls each tangent toward its own segment’s chord.
Let’s write the smoothing strength as s ∈ [0,1]. In the interface it’s a
percentage from 0 to 100. For a segment with chord d:
Where T₀ and T₁ are those same Catmull-Rom tangents we computed above, d
is the current segment’s chord, and T′ is the result of the blend.
It’s T′ that goes into the Hermite curve. At Smoothness = 100% the prime can
be dropped: T′ coincides with T there, which is why we haven’t been telling
them apart until now.
Let’s look at the ends of the range.
At s = 1 the tangents are left untouched. That’s full Hermite.
At s = 0 both tangents become equal to the segment’s chord. And here’s the
beautiful part: a cubic Hermite curve whose tangents both equal its own chord
is exactly a straight line.
Why Hermite with T′₀ = T′₁ = d degenerates into a straight line
Substituting T′₀ = T′₁ = d = V₁ − V₀:
The cubic and quadratic terms cancel out.
So the slider doesn’t switch between two modes. It continuously deforms the same cubic from a straight line into a fully smoothed curve, changing only the slope at its ends.
This also means the broken line we started the tangent discussion with isn’t an invention for the sake of illustration. It’s a perfectly reachable mode: just take the slider down to zero.
Does it matter what gets blended?
It is worth pausing here, because an objection suggests itself. The Hermite curve depends linearly on its tangents: feed it a sum and you get the sum of the results. This means that blending the tangents is mathematically equivalent to blending the two finished curves, the linear one and the full Hermite:
On the left is what Classic does. On the right is the interpretation we started
with. And H(d) is what we just computed: a straight line.
So there’s no way to tell the two descriptions apart by looking at the picture until the clamp enters the scene. The clamp is not linear, and this is where the order of operations starts to matter: cutting the overshoot off an already blended curve is not the same as blending a straight line with an already clamped Hermite.
Take the slider down to zero: the tangents collapse onto the chords, and all that's left of the curve is a polyline of straight pieces. The bends disappear, and so does the overshoot. There's nothing left to clamp.
Classic does it in this order: first Smoothness modifies the tangents, then the curve is built from them, and only the finished value gets clamped to the segment’s range.
The whole curve
Let’s put it all together. For a single channel the chain looks like this:
- the channel's values at the stops
- 1find the segment and normalize the position inside it
- 2remap the position through the midpoint
- 3the current chord and its two neighbors
- 4tangents, pulled toward the chord by Smoothness
- 5cubic Hermite spline
- 6clamp to the values at the ends of the segment
- the channel's value
And if we unfold that chain into code, here it is in full, with nothing left out:
Pseudocode: one procedure per channel
CLASSIC-CHANNEL(stops, midpoints, s, x)
# 0. beyond the outermost stops their color holds if x ≤ position of the first stop: return its value if x ≥ position of the last stop: return its value
# 1. the segment x belongs to i ← index of the segment containing x p₀, p₁ ← positions of its stops V₀, V₁ ← values of its stops u ← (x − p₀) / (p₁ − p₀)
# 2. remap through the midpoint m ← midpoints[i] t ← u/(2m) if u ≤ m ½ + (u−m)/(2(1−m)) otherwise
# 3. the current chord and its two neighbors, # a missing one counts as zero d ← V₁ − V₀ d_prv ← V₀ − value of stop i−1 d_nxt ← value of stop i+2 − V₁
# 4. Catmull-Rom tangents T₀ ← (d + d_prv)/2 T₁ ← (d + d_nxt)/2
# 5. and the blend with the chord according to Smoothness T′₀ ← (1−s)·d + s·T₀ T′₁ ← (1−s)·d + s·T₁
# 6. cubic Hermite spline H ← (2t³ − 3t² + 1)·V₀ + (t³ − 2t² + t)·T′₀ + (−2t³ + 3t²)·V₁ + (t³ − t²)·T′₁
# 7. clamp to the segment's range return min(max(H, min(V₀,V₁)), max(V₀,V₁))For a color this procedure is called three times, once each for red, green and blue. The three results are reassembled into RGB, and the Classic curve is done.
There isn’t a single fitted coefficient in the curve itself: every step described above is fully determined by the stops, the midpoints and Smoothness. The plots above visualize those same steps. They aren’t drawn by hand.
Are we done with Classic?
Last time we left behind a table where every cell was a question mark. One column can be filled in right now:
| Step | Classic | Linear | Perceptual | Smooth |
|---|---|---|---|---|
| Color space | the document’s RGB, no conversion | ??? | ??? | ??? |
| Curve | Hermite spline | ??? | ??? | ??? |
| Tangent | Catmull-Rom, blended by Smoothness | ??? | ??? | ??? |
| Post-processing | clamp to the segment’s range | ??? | ??? | ??? |
But while we were filling in the Classic column, it became clear that the table itself was incomplete. Color space, curve, tangents, and post-processing describe the interpolation, but not the whole path from a gradient to the final pixels. When we sketched out these rows last time, we didn’t yet know that there were more stages between interpolation and the final pixels.
What’s next
The curve is done. We know where the tangents come from, which formula computes the value, what happens with overshoot, and what Smoothness actually does.
All that’s left is to compare the result with what Photoshop itself draws. And this is where it gets interesting.
The curve matches. The pixels don't.
The discrepancy is tiny: even at its worst point it is less than one code value on the 0-to-255 scale, and usually it stays around one-tenth of a level. Nothing you could ever see by eye, on any gradient.
But it’s there. It’s stable, repeats from render to render, and doesn’t go away no matter how you fix the formulas.
Because it isn’t in the curve.
It isn’t even in Classic.
The cause lies deeper.
We missed a stage shared by all four methods. Classic simply leaves the discrepancy nowhere to hide: there are no color conversions to blame it on.