Skip to content

Commit 2797626

Browse files
authored
Upgrade to ColorAide 1.4 (#236)
1 parent 45d9274 commit 2797626

27 files changed

+453
-162
lines changed

ch_native_picker.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ class MacPick(_ColorPicker):
5454
def pick(self):
5555
"""Pick the color."""
5656

57-
color = self.color.convert('srgb')
57+
color = self.color.convert('srgb').normalize()
5858
coords = [x * UINT for x in color.clone().fit()[:-1]]
5959
try:
6060
p = subprocess.Popen(
@@ -119,7 +119,7 @@ def set_win_pick_colors(self, colors):
119119
def pick(self):
120120
"""Pick the color."""
121121

122-
color = self.color.convert('srgb')
122+
color = self.color.convert('srgb').normalize()
123123
hx = color.to_string(hex=True, alpha=False)[1:]
124124
bgr = int(hx[4:6] + hx[2:4] + hx[0:2], 16)
125125

@@ -150,7 +150,7 @@ class LinuxPick(_ColorPicker):
150150
def pick(self):
151151
"""Pick the color."""
152152

153-
color = self.color.convert('srgb')
153+
color = self.color.convert('srgb').normalize()
154154
hx = color.to_string(hex=True, alpha=False)
155155
try:
156156
p = subprocess.Popen(

lib/coloraide/__meta__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,5 +192,5 @@ def parse_version(ver: str) -> Version:
192192
return Version(major, minor, micro, release, pre, post, dev)
193193

194194

195-
__version_info__ = Version(1, 1, 0, "final")
195+
__version_info__ = Version(1, 4, 0, "final")
196196
__version__ = __version_info__._get_canonical()

lib/coloraide/algebra.py

Lines changed: 261 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
import functools
2727
from itertools import zip_longest as zipl
2828
from .types import ArrayLike, MatrixLike, VectorLike, Array, Matrix, Vector, SupportsFloatOrInt
29-
from typing import Optional, Callable, Sequence, List, Union, Iterator, Tuple, Any, Iterable, overload, cast
29+
from typing import Optional, Callable, Sequence, List, Union, Iterator, Tuple, Any, Iterable, overload, Dict, cast
3030

3131
NaN = float('nan')
3232
INF = float('inf')
@@ -41,7 +41,7 @@ def prod(values: Iterable[SupportsFloatOrInt]) -> SupportsFloatOrInt:
4141
if not values:
4242
return 1
4343

44-
return functools.reduce(lambda x, y: x * y, values)
44+
return functools.reduce(operator.mul, values)
4545

4646
# Shortcut for math operations
4747
# Specify one of these in divide, multiply, dot, etc.
@@ -66,6 +66,9 @@ def prod(values: Iterable[SupportsFloatOrInt]) -> SupportsFloatOrInt:
6666
D2_D1 = (2, 1)
6767
DN_DM = (3, 3)
6868

69+
# Vector used to create a special matrix used in natural splines
70+
M141 = [1, 4, 1]
71+
6972

7073
################################
7174
# General math
@@ -155,10 +158,257 @@ def npow(base: float, exp: float) -> float:
155158
return math.copysign(abs(base) ** exp, base)
156159

157160

158-
def lerp(a: float, b: float, t: float) -> float:
161+
################################
162+
# Interpolation and splines
163+
################################
164+
def lerp(p0: float, p1: float, t: float) -> float:
159165
"""Linear interpolation."""
160166

161-
return a + (b - a) * t
167+
return p0 + (p1 - p0) * t
168+
169+
170+
@functools.lru_cache(maxsize=10)
171+
def _matrix_141(n: int) -> Matrix:
172+
"""Get matrix '1 4 1'."""
173+
174+
m = [[0] * n for _ in range(n)] # type: Matrix
175+
m[0][0:2] = M141[1:]
176+
m[-1][-2:] = M141[:-1]
177+
for x in range(n - 2):
178+
m[x + 1][x:x + 3] = M141
179+
return inv(m)
180+
181+
182+
def naturalize_bspline_controls(coordinates: List[Vector]) -> None:
183+
"""
184+
Given a set of B-spline control points in the Nth dimension, create new naturalized interpolation control points.
185+
186+
Using the color points as `S0...Sn`, calculate `B0...Bn`, such that interpolation will
187+
pass through `S0...Sn`.
188+
189+
When given 2 data points, the operation will be returned as linear, so there is nothing to do.
190+
"""
191+
192+
n = len(coordinates) - 2
193+
194+
# Special case 3 data points
195+
if n == 1:
196+
coordinates[1] = [
197+
(a * 6 - (b + c)) / 4 for a, b, c in zip(coordinates[1], coordinates[0], coordinates[2])
198+
]
199+
200+
# Handle all other cases where n does not result in linear interpolation
201+
elif n > 1:
202+
# Create [1, 4, 1] matrix for size `n` set of control points
203+
m = _matrix_141(n)
204+
205+
# Create C matrix from the data points
206+
c = []
207+
for r in range(1, n + 1):
208+
if r == 1:
209+
c.append([a * 6 - b for a, b in zip(coordinates[r], coordinates[r - 1])])
210+
elif r == n:
211+
c.append([a * 6 - b for a, b in zip(coordinates[n], coordinates[n + 1])])
212+
else:
213+
c.append([a * 6 for a in coordinates[r]])
214+
215+
# Dot M^-1 and C to get B (control points)
216+
v = dot(m, c, dims=D2)
217+
for r in range(1, n + 1):
218+
coordinates[r] = v[r - 1]
219+
220+
221+
def bspline(p0: float, p1: float, p2: float, p3: float, t: float) -> float:
222+
"""Calculate the new point using the provided values."""
223+
224+
# Save some time calculating this once
225+
t2 = t ** 2
226+
t3 = t2 * t
227+
228+
# Insert control points to algorithm
229+
return (
230+
((1 - t) ** 3) * p0 + # B0
231+
(3 * t3 - 6 * t2 + 4) * p1 + # B1
232+
(-3 * t3 + 3 * t2 + 3 * t + 1) * p2 + # B2
233+
t3 * p3 # B3
234+
) / 6
235+
236+
237+
def catrom(p0: float, p1: float, p2: float, p3: float, t: float) -> float:
238+
"""Calculate the new point using the provided values."""
239+
240+
# Save some time calculating this once
241+
t2 = t ** 2
242+
t3 = t2 * t
243+
244+
# Insert control points to algorithm
245+
return (
246+
(-t3 + 2 * t2 - t) * p0 + # B0
247+
(3 * t3 - 5 * t2 + 2) * p1 + # B1
248+
(-3 * t3 + 4 * t2 + t) * p2 + # B2
249+
(t3 - t2) * p3 # B3
250+
) / 2
251+
252+
253+
def monotone(p0: float, p1: float, p2: float, p3: float, t: float) -> float:
254+
"""
255+
Monotone spline based on Hermite.
256+
257+
We calculate our secants for our four samples (the center pair being our interpolation target).
258+
259+
From those, we calculate an initial gradient, and test to see if it is needed. In the event
260+
that our there is no increase or decrease between the point, we can infer that the gradient
261+
should be horizontal. We also test if they have opposing signs, if so, we also consider the
262+
gradient to be zero.
263+
264+
Lastly, we ensure that the gradient is confined within a circle with radius 3 as it has been
265+
observed that such a circle encapsulates the entire monotonicity region.
266+
267+
Once gradients are calculated, we simply perform the Hermite spline calculation and clean up
268+
floating point math errors to ensure monotonicity.
269+
270+
We could build up secant and gradient info ahead of time, but currently we do it on the fly.
271+
272+
http://jbrd.github.io/2020/12/27/monotone-cubic-interpolation.html
273+
https://ui.adsabs.harvard.edu/abs/1990A%26A...239..443S/abstract
274+
https://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.39.6720
275+
https://en.wikipedia.org/w/index.php?title=Monotone_cubic_interpolation&oldid=950478742
276+
"""
277+
278+
# Save some time calculating this once
279+
t2 = t ** 2
280+
t3 = t2 * t
281+
282+
# Calculate the secants for the differing segments
283+
s0 = p1 - p0
284+
s1 = p2 - p1
285+
s2 = p3 - p2
286+
287+
# Calculate initial gradients
288+
m1 = (s0 + s1) * 0.5
289+
m2 = (s1 + s2) * 0.5
290+
291+
# Center segment should be horizontal as there is no increase/decrease between the two points
292+
if math.isclose(p1, p2):
293+
m1 = m2 = 0.0
294+
else:
295+
296+
# Gradient is zero if segment is horizontal or if the left hand secant differs in sign from current.
297+
if math.isclose(p0, p1) or (math.copysign(1.0, s0) != math.copysign(1.0, s1)):
298+
m1 = 0.0
299+
300+
# Ensure gradient magnitude is either 3 times the left or current secant (smaller being preferred).
301+
else:
302+
m1 *= min(3.0 * s0 / m1, min(3.0 * s1 / m1, 1.0))
303+
304+
# Gradient is zero if segment is horizontal or if the right hand secant differs in sign from current.
305+
if math.isclose(p2, p3) or (math.copysign(1.0, s1) != math.copysign(1.0, s2)):
306+
m2 = 0.0
307+
308+
# Ensure gradient magnitude is either 3 times the current or right secant (smaller being preferred).
309+
else:
310+
m2 *= min(3.0 * s1 / m2, min(3.0 * s2 / m2, 1.0))
311+
312+
# Now we can evaluate the Hermite spline
313+
result = (
314+
(m1 + m2 - 2 * s1) * t3 +
315+
(3.0 * s1 - 2.0 * m1 - m2) * t2 +
316+
m1 * t +
317+
p1
318+
)
319+
320+
# As the spline is monotonic, all interpolated values should be confined between the endpoints.
321+
# Floating point arithmetic can cause this to be out of bounds on occasions.
322+
mn = min(p1, p2)
323+
mx = max(p1, p2)
324+
return min(max(result, mn), mx)
325+
326+
327+
SPLINES = {
328+
'natural': bspline,
329+
'bspline': bspline,
330+
'catrom': catrom,
331+
'monotone': monotone,
332+
'linear': lerp
333+
} # type: Dict[str, Callable[..., float]]
334+
335+
336+
class Interpolate:
337+
"""Interpolation object."""
338+
339+
def __init__(
340+
self,
341+
points: List[Vector],
342+
callback: Callable[..., float],
343+
length: int,
344+
linear: bool = False
345+
) -> None:
346+
"""Initialize."""
347+
348+
self.length = length
349+
self.num_coords = len(points[0])
350+
self.points = list(zip(*points))
351+
self.callback = callback
352+
self.linear = linear
353+
354+
def steps(self, count: int) -> List[Vector]:
355+
"""Generate steps."""
356+
357+
divisor = count - 1
358+
return [self(r / divisor) for r in range(0, count)]
359+
360+
def __call__(self, t: float) -> Vector:
361+
"""Interpolate."""
362+
363+
n = self.length - 1
364+
i = max(min(math.floor(t * n), n - 1), 0)
365+
t = (t - i / n) * n if 0 <= t <= 1 else t
366+
if not self.linear:
367+
i += 1
368+
369+
# Iterate the coordinates and apply the spline to each component
370+
# returning the completed, interpolated coordinate set.
371+
coord = []
372+
for idx in range(self.num_coords):
373+
c = self.points[idx]
374+
if self.linear or t < 0 or t > 1:
375+
coord.append(lerp(c[i], c[i + 1], t))
376+
else:
377+
coord.append(
378+
self.callback(
379+
c[i - 1],
380+
c[i],
381+
c[i + 1],
382+
c[i + 2],
383+
t
384+
)
385+
)
386+
387+
return coord
388+
389+
390+
def interpolate(points: List[Vector], method: str = 'linear') -> Interpolate:
391+
"""Generic interpolation method."""
392+
393+
points = points[:]
394+
length = len(points)
395+
396+
# Natural requires some preprocessing of the B-spline points.
397+
if method == 'natural':
398+
naturalize_bspline_controls(points)
399+
400+
# Get the spline method
401+
s = SPLINES[method]
402+
linear = method == 'linear'
403+
404+
# Clamp end points
405+
if not linear:
406+
start = [2 * a - b for a, b in zip(points[0], points[1])]
407+
end = [2 * a - b for a, b in zip(points[-1], points[-2])]
408+
points.insert(0, start)
409+
points.append(end)
410+
411+
return Interpolate(points, s, length, linear)
162412

163413

164414
################################
@@ -167,7 +417,10 @@ def lerp(a: float, b: float, t: float) -> float:
167417
def vdot(a: VectorLike, b: VectorLike) -> float:
168418
"""Dot two vectors."""
169419

170-
return sum([x * y for x, y in zipl(a, b)])
420+
s = 0.0
421+
for x, y in zipl(a, b):
422+
s += x * y
423+
return s
171424

172425

173426
def vcross(v1: VectorLike, v2: VectorLike) -> Vector: # pragma: no cover
@@ -967,9 +1220,8 @@ class BroadcastTo:
9671220
By flattening the data, we are able to slice out the bits we need in the order we need
9681221
and duplicate them to expand the matrix to fit the provided shape.
9691222
970-
We need 4 things to do this:
1223+
We need 3 things to do this:
9711224
- The original array.
972-
- The original array shape.
9731225
- The stage 1 array shape (with prepended 1s). This helps us calculate our loop iterations.
9741226
- The new shape.
9751227
"""
@@ -1059,8 +1311,8 @@ def __next__(self) -> float:
10591311
self._loop2 = self.expand
10601312

10611313
if self._chunk_index >= self._chunk_max:
1062-
# We are actually at then of all the data, let's see
1063-
# if we need to process all the data again.
1314+
# We are actually at the end of all the data,
1315+
# let's see if we need to process all the data again.
10641316
self._loop1 -= 1
10651317
if self._loop1:
10661318
# We need to keep going

lib/coloraide/color.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
from .interpolate import Interpolator, Interpolate
5757
from .interpolate.bspline import BSpline
5858
from .interpolate.bspline_natural import NaturalBSpline
59+
from .interpolate.monotone import Monotone
5960
from .interpolate.linear import Linear
6061
from .types import Plugin
6162
from typing import overload, Union, Sequence, Dict, List, Optional, Any, cast, Callable, Tuple, Type, Mapping
@@ -948,6 +949,7 @@ def set( # noqa: A003
948949
# Interpolation
949950
Linear(),
950951
BSpline(),
951-
NaturalBSpline()
952+
NaturalBSpline(),
953+
Monotone()
952954
]
953955
)

0 commit comments

Comments
 (0)