culori icon indicating copy to clipboard operation
culori copied to clipboard

closed interpolation outside [0, 1]?

Open Fil opened this issue 4 years ago • 8 comments

I wanted to use the closed interpolator outside [0,1], so in principle I thought it would be periodic; but in fact its domain is [0, 1 + 1/n], and it "crashes" outside.

Here's how I "fix" this:

culoriClosed = array => {
  const f = culori.interpolateSplineMonotone(d => d, "closed", 1)(array),
    n = array.length - 1,
    k = 1 + 1 / n;
  return t => f(k * (t - Math.floor(t)));
}

now the domain of culoriClosed is [0, 1] (we have a complete curve if t in [0, 1]), and we have f(t + integer) === f(t).

https://observablehq.com/d/f2e94a3321c17726 draws a spiral with -5 < t < 30, and the main domain as a loop, in heavy black.

image

Fil avatar Jul 03 '20 10:07 Fil

Oops? This chart left we wondering if this was really a "monotone cubic" interpolator… as it overshoots in X and Y. So I think there is a small mistake here, the function is a standard cubic not a monotone cubic?

Fil avatar Jul 03 '20 10:07 Fil

Thanks for bringing this up! I'll have to get back to speed, but the spline should be implemented based on this paper that d3-shape mentions (I don't know why I failed to leave a comment with the reference). It states:

Construct a piecewise cubic interpolation function that passes through N data points in a monotonic way with the slope of the curve changing continuously across the junction points.

The monotone() function here:

https://github.com/Evercoder/culori/blob/a846314e1ace53e129586ba41bd44e007b43e7bc/src/interpolate/splineMonotone.js#L3-L18

...should implement the equations (1) – (7) from page 445 (3 of 8 in the PDF). However I can't remember how I derived s20 and s31 (which correspond to y'(i) and y'(i+1) from the paper), and I'll have to review the paper and the code to figure out what's going on.

It does indeed seem the code produces a non-monotone result.

As for the initial issue you raised, I had t pinned to [0, 1] in my mind, but it makes sense to offer something better than crashing.

I'll take a look and get back, thanks again!

danburzo avatar Jul 03 '20 15:07 danburzo

In [email protected] the function looks like this:

https://github.com/Evercoder/culori/blob/8e64a2515a4e6f5dfb89f0e778e7fbe4ad35643e/src/interpolate/splineMonotone.js#L23-L45

(I changed the variables a bit so I can better keep track of i-1, i, etc.)

Basically I had forgotten to apply equation (11) from the paper.

I still need to add some tests for the spline, but adding it to the Observable notebooks seems to indicate the problem is fixed. Note that this particular spline, in the way it's implemented here, is only applicable to evenly spaced values along the x axis, so the drawing looks a bit weird now.

danburzo avatar Jul 03 '20 16:07 danburzo

Excellent, thanks a lot! Indeed this is how the monotone spiral is supposed to look if we use it for the parametrized curve (x(t), y(t)).

Capture d’écran 2020-07-03 à 19 34 14

I'll try and port it to d3-interpolate as well. I'm leaving this issue open since it's initially about the domain for the closed version.

Fil avatar Jul 03 '20 17:07 Fil

I've given closed splines further thought and I'm convinced at least the monotone one needs adjustments. What follows is me talking out loud :P

A basis spline can be clamped (the default), open, or closed. It's only for the clamped version that we have the property that t = 0 corresponds to the first color in the array, and t = 1 to the last; in general, a basis spline does not interpolate (pass through) it's control points at all.

Now, for interpolating splines (such as the monotone spline):

  • The normal version (can we still call it clamped?) has monotone(0) = colors[0] and monotone(1) = colors[colors.length - 1]
  • A closed monotone spline that interpolates the N colors will have to create space for an additional 1/N interval, to connect the first and last colors smoothly — where does it make sense to add the extra segment? at the beginning (meaning monotone(0) = monotone(1) = colors[colors.length - 1]), at the end (monotone(0) = monotone(1) = colors[0]), or half-and-half? (meaning neither of the two conditions are no longer true)

And, since the goal is to be able to swap a linear interpolation with a spline interpolation seamlessly, does that mean that piecewise-linear interpolation also needs a "closed" concept?

And, is "closed" as a term too closely tied to the B-spline? Should it be called cyclical or something to that effect?

Part of my problem with color interpolation is that I can't find a good abstraction to allow all the possible combinations without spelling them out explicitly and duplicating implementations (see #80):

  • clamped vs. closed interpolation
  • linear vs. spline interpolation
  • different interpolation method per channel
  • ability to control the normalization of special channels: e.g. for hue, shortest vs. longest path, clockwise, anticlockwise?

danburzo avatar Jul 04 '20 13:07 danburzo

  1. If you consider a "closed" polygon as having p[n-1] == p[0], the issue of having to "add 1/n" disappears. "Forgetting" to pass the closing value is just a convenience notation.

  2. It would certainly be nice to have a closed piecewise-linear function, for completion.

  3. "closed" is fine as a term, but (as you've just seen) I view it as meaning "continuous cyclical"—C₂ continuous for the cubic versions.

  4. "clamped" is a better term than "default" ; "open" isn't really meaningful except for basis, I think?

  5. Some color spaces require a circular interpolation on one dimension (hue), which is not yet available in our system. When hue goes from 350° to 10° it should "interpolate" via 0° (shortest path//geodesic), which basis/cubic/monotone don't have. It might be possible to split this into cos/sin channels, interpolate them and compute the atan2 of the result, but this approach seems a bit heavy-handed :)

  6. "Longest path" is a bad name : what you want is to describe a complete journey in the color space. In d3-geo when we do this (for example to describe the equator as a LineString), we never write "take the longest path from [0,0] to [0,0]" but instead say "go from -180 to -90, then 0°, then 90°, then 180°"—and each part of the journey follows a geodesic. (This also solves the question of anti/clockwise.)

Fil avatar Jul 04 '20 14:07 Fil

"open" isn't really meaningful except for basis, I think?

I guess parallels from basis splines to other splines are not too far-fetched; in d3-shape they're implemented with the idea that in the open version of an interpolating spline (cardinal, Catmull-Rom), you "sacrifice" the first and last value in the array to be used merely as control points (to adjust the curve's slope at either ends) without having the curve pass through them. That would work equally well for the monotone spline. But yeah, I'm not sure if they're that meaningful / useful when interpolating colors specifically...

Some color spaces require a circular interpolation on one dimension (hue), which is not yet available in our system.

I've tried to solve this by tentatively introducing culori.interpolateHue as a sort of normalization function; for example, the default interpolation in lch looks like this:

	interpolate: {
		h: interpolateLinear(interpolateHue),
		c: interpolateLinear(),
		l: interpolateLinear(),
		alpha: interpolateLinear(interpolateAlpha)
	}

However, the API strikes me as a bit convoluted....

danburzo avatar Jul 04 '20 17:07 danburzo

Ah yes https://github.com/Evercoder/culori/blob/master/src/interpolate/hue.js#L20

Fil avatar Jul 04 '20 21:07 Fil