Skip to content

Commit 515e1ff

Browse files
committed
Interpolation can have reversed domains
1 parent 2d4f62e commit 515e1ff

File tree

6 files changed

+85
-52
lines changed

6 files changed

+85
-52
lines changed

coloraide/algebra.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"""
2020
from __future__ import annotations
2121
import builtins
22+
import bisect
2223
import decimal
2324
import sys
2425
import cmath
@@ -261,6 +262,20 @@ def polar_to_rect(c: float, h: float) -> tuple[float, float]:
261262
return c * math.cos(r), c * math.sin(r)
262263

263264

265+
def reversed_bisect_left(a: Vector, x: float, lo: int = 0, hi: int | None = None) -> int:
266+
"""Perform bisect left on a reversed list."""
267+
268+
if hi is None:
269+
hi = len(a)
270+
while lo < hi:
271+
mid = (lo + hi) // 2
272+
if x >= a[mid]:
273+
hi = mid
274+
else:
275+
lo = mid + 1
276+
return lo
277+
278+
264279
def solve_bisect(
265280
low: float,
266281
high: float,
@@ -767,7 +782,8 @@ def __init__(
767782
self.num_coords = len(points[0])
768783
self.preprocess(points)
769784
self.points = [*zip(*points)]
770-
self.domain = domain[:] if domain is not None else domain
785+
self.domain = list(domain) if domain is not None else domain
786+
self.increasing = not self.domain or len(self.domain) == 1 or self.domain[1] > self.domain[0]
771787

772788
@classmethod
773789
def preprocess(cls, points: list[Vector]) -> None:
@@ -796,17 +812,29 @@ def handle_domain(self, t: float) -> float:
796812
if self.domain is None:
797813
return t
798814

815+
import operator as op
816+
le, ge = (op.le, op.ge) if self.increasing else (op.ge, op.le)
817+
799818
# Extrapolation
800-
if t <= self.domain[0]:
819+
if le(t, self.domain[0]):
801820
t = (t - self.domain[0]) / (self.domain[-1] - self.domain[0])
802-
elif t >= self.domain[-1]:
821+
elif ge(t, self.domain[-1]):
803822
t = 1.0 + (t - self.domain[-1]) / (self.domain[-1] - self.domain[0])
804823

805824
# Interpolation
806825
else:
807-
a, b = self.domain[0], self.domain[len(self.domain) - 1]
808-
l = b - a
809-
t = ((t - a) / l) if l else 0.0
826+
bisect_left = bisect.bisect_left if self.increasing else reversed_bisect_left
827+
regions = len(self.domain) - 1
828+
size = (1 / regions)
829+
index = bisect_left(self.domain, t) - 1
830+
adjusted = 0.0
831+
if index < regions:
832+
a, b = self.domain[index:index + 2]
833+
l = b - a
834+
adjusted = ((t - a) / l) if l else 0.0
835+
else:
836+
index = regions - 1
837+
t = size * index + (adjusted * size)
810838
return t
811839

812840
def __call__(self, t: float) -> Vector:
@@ -859,7 +887,7 @@ def run(self, i: int, t: float) -> Vector:
859887

860888

861889
class CatmullRomInterpolator(_CubicInterpolator):
862-
"""Catrom-Mull interpolator."""
890+
"""Catmull-Rom interpolator."""
863891

864892
@staticmethod
865893
def interpolate(p0: float, p1: float, p2: float, p3: float, t: float) -> float:
@@ -989,7 +1017,7 @@ def _matrix_141(n: int) -> Matrix:
9891017

9901018

9911019
class NaturalBSplineInterpolator(BSplineInterpolator):
992-
"""Natrual B-Spline interpolator."""
1020+
"""Natural B-Spline interpolator."""
9931021

9941022
@staticmethod
9951023
def naturalize(points: list[Vector]) -> None:

coloraide/cmfs.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ class CMFs(dict[float, Vector]):
1010
"""A color matching function wrapper for CMFs."""
1111

1212
def __init__(self, cmfs: dict[float, Vector], /, interpolator: str = 'cubic', **kwargs: Any):
13-
"""Initalize and capture attributes of CMF."""
13+
"""Initialize and capture attributes of CMF."""
1414

1515
keys = list(cmfs.keys())
1616
self.spline = alg.interpolate(list(cmfs.values()), method='sprague')
@@ -30,7 +30,7 @@ def __getitem__(self, key: float) -> Vector:
3030
return self.spline(factor)
3131

3232
def xy(self, key: float) -> Vector:
33-
"""Return CMFs data as XY coordiantes."""
33+
"""Return CMFs data as XY coordinates."""
3434

3535
return util.xyz_to_xyY(self[key])[:-1]
3636

coloraide/illuminants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ class Illuminant(dict[float, float]):
88
"""Spectral distribution of illuminants."""
99

1010
def __init__(self, illuminant: dict[float, float], /, **kwargs: Any):
11-
"""Initalize and capture attributes of CMF."""
11+
"""Initialize and capture attributes of CMF."""
1212

1313
keys = list(illuminant.keys())
1414
self.spline = alg.interpolate([[i]for i in illuminant.values()], method='linear')

coloraide/interpolate/__init__.py

Lines changed: 28 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"""
1616
from __future__ import annotations
1717
import math
18+
import bisect
1819
import functools
1920
from abc import ABCMeta, abstractmethod
2021
from .. import algebra as alg
@@ -101,6 +102,7 @@ def __init__(
101102
self.extrapolate = extrapolate
102103
self.current_easing = None # type: Mapping[str, Callable[..., float]] | Callable[..., float] | None
103104
self.hue = hue
105+
self.increasing = True
104106
cs = self.color_cls.CS_MAP[space]
105107
if cs.is_polar():
106108
self.hue_index = cs.hue_index() # type: ignore[attr-defined]
@@ -119,6 +121,7 @@ def __init__(
119121

120122
# Set the domain
121123
self._domain = [] # type: Vector
124+
122125
if domain is not None:
123126
self.domain(domain)
124127

@@ -186,6 +189,12 @@ def out_space(self, space: str) -> None:
186189
raise ValueError(f"'{space}' is not a valid color space")
187190
self._out_space = space
188191

192+
def domain(self, domain: Sequence[float]) -> None:
193+
"""Set the domain."""
194+
195+
self._domain = list(domain)
196+
self.increasing = not domain or len(domain) == 1 or domain[1] > domain[0]
197+
189198
def padding(self, padding: float | Sequence[float]) -> None:
190199
"""Add/adjust padding."""
191200

@@ -215,28 +224,6 @@ def padding(self, padding: float | Sequence[float]) -> None:
215224
else:
216225
self._padding = (0.0 + padding[0], 1.0 - padding[1])
217226

218-
def domain(self, domain: Sequence[float]) -> None:
219-
"""Set the domain."""
220-
221-
# Ensure domain ascends.
222-
# If we have a domain of length 1, we will duplicate it.
223-
d = [] # type: Vector
224-
if domain:
225-
length = len(domain)
226-
227-
# Ensure values are not descending
228-
d.append(domain[0])
229-
for index in range(length - 1):
230-
b = domain[index + 1]
231-
d.append(d[-1] if b <= d[-1] else b)
232-
233-
# We need at least two values, so duplicate the first.
234-
if len(d) == 1:
235-
d.append(d[0])
236-
domain = d
237-
238-
self._domain = d
239-
240227
@abstractmethod
241228
def setup(self) -> None:
242229
"""Setup."""
@@ -405,38 +392,42 @@ def ease(self, t: float, channel_index: int) -> float:
405392

406393
return progress(t) if progress is not None else t
407394

408-
def scale(self, point: float) -> float:
395+
def handle_domain(self, p: float) -> float:
409396
"""
410397
Scale a point from a custom domain into a domain of 0 to 1.
411398
412399
This allows a user to have a custom domain, but for us to adapt back to 0 and 1
413400
so that our logic can remain consistent.
414401
"""
415402

416-
if point < self._domain[0]:
417-
point = (point - self._domain[0]) / (self._domain[-1] - self._domain[0]) if self.extrapolate else 0.0
418-
elif point > self._domain[-1]:
419-
point = 1.0 + (point - self._domain[-1]) / (self._domain[-1] - self._domain[0]) if self.extrapolate else 1.0
403+
import operator as op
404+
405+
le, ge = (op.le, op.ge) if self.increasing else (op.ge, op.le)
406+
bisect_left = bisect.bisect_left if self.increasing else alg.reversed_bisect_left
407+
408+
if le(p, self._domain[0]):
409+
p = (p - self._domain[0]) / (self._domain[-1] - self._domain[0]) if self.extrapolate else 0.0
410+
elif ge(p, self._domain[-1]):
411+
p = 1.0 + (p - self._domain[-1]) / (self._domain[-1] - self._domain[0]) if self.extrapolate else 1.0
420412
else:
421413
regions = len(self._domain) - 1
422414
size = (1 / regions)
423-
index = 0
415+
index = bisect_left(self._domain, p) - 1
424416
adjusted = 0.0
425-
for index in range(regions):
417+
if index < regions:
426418
a, b = self._domain[index:index + 2]
427-
if point >= a and point <= b:
428-
l = b - a
429-
adjusted = ((point - a) / l) if l else 0.0
430-
break
431-
432-
point = size * index + (adjusted * size)
433-
return point
419+
l = b - a
420+
adjusted = ((p - a) / l) if l else 0.0
421+
else:
422+
index = regions - 1
423+
p = size * index + (adjusted * size)
424+
return p
434425

435426
def __call__(self, point: float) -> AnyColor:
436427
"""Find which leg of the interpolation the request is between."""
437428

438429
if self._domain:
439-
point = self.scale(point)
430+
point = self.handle_domain(point)
440431

441432
if self._padding:
442433
slope = (self._padding[1] - self._padding[0])

docs/src/markdown/about/changelog.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ icon: lucide/scroll-text
77

88
- **NEW**: Refactor interpolation helpers and add Sprague interpolation.
99
- **NEW**: Refactor handling of CMFs, and when interpolating them use Sprague interpolation.
10-
- **NEW**: Use Sprague interpolation for the Ohno 2013 spline impelementation to reduce needed points.
10+
- **NEW**: Use Sprague interpolation for the Ohno 2013 spline implementation to reduce needed points.
11+
- **NEW**: Color interpolation can now have domains in reversed, sorted order.
1112
- **NEW**: Discard unused CMFs, other than CIE 1931 2˚.
1213
- **NEW**: Rename internal file.
1314

docs/src/markdown/interpolation.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -801,9 +801,22 @@ Color.interpolate(
801801
)
802802
```
803803

804-
Lastly, domains must be specified in ascending order of values. If a value decreases in magnitude, it will assume the
805-
value that comes right before it. This means you cannot put a domain in reverse. If you need to reverse the order, just
806-
flip the color order and setup the domain accordingly.
804+
Domains can also be specified in descending order.
805+
806+
807+
```py play
808+
Color.interpolate(
809+
['blue', 'green', 'yellow', 'orange', 'red'],
810+
domain=[95, 85, 60, 32, -32]
811+
)
812+
Color.interpolate(
813+
['blue', 'green', 'yellow', 'orange', 'red'],
814+
domain=[95, -32]
815+
)
816+
```
817+
818+
Lastly, while domains can be specified in either ascending or descending order, it needs to be consistent. If a values
819+
increase in values, and suddenly decreases in value, you will get discontinuities.
807820

808821
```py play
809822
i = Color.interpolate(

0 commit comments

Comments
 (0)