densitty 0.8.2__py3-none-any.whl → 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
densitty/detect.py CHANGED
@@ -9,14 +9,15 @@ from types import MappingProxyType
9
9
  from typing import Any, Callable, Optional, Sequence
10
10
  import time
11
11
 
12
- from . import ansi, ascii_art, binning, lineart, truecolor
12
+ from . import ansi, ascii_art, binning, lineart, smoothing, truecolor
13
13
  from . import plot as plotmodule
14
14
  from .util import FloatLike, ValueRange
15
15
 
16
16
  if sys.platform == "win32":
17
17
  # pylint: disable=import-error
18
18
  import ctypes
19
- from ctypes.windll.kernel32 import GetConsoleMode, GetStdHandle, SetConsoleMode
19
+
20
+ kernel32 = ctypes.windll.kernel32
20
21
  else:
21
22
  # All other platforms should have TERMIOS available
22
23
  import fcntl
@@ -86,10 +87,10 @@ if sys.platform == "win32":
86
87
  """Windows-based wrapper to avoid control code output to stdout"""
87
88
  prev_stdin_mode = ctypes.wintypes.DWORD(0)
88
89
  prev_stdout_mode = ctypes.wintypes.DWORD(0)
89
- GetConsoleMode(GetStdHandle(-10), ctypes.byref(prev_stdin_mode))
90
- SetConsoleMode(GetStdHandle(-10), 0)
91
- GetConsoleMode(GetStdHandle(-11), ctypes.byref(prev_stdout_mode))
92
- SetConsoleMode(GetStdHandle(-11), 7)
90
+ kernel32.GetConsoleMode(kernel32.GetStdHandle(-10), ctypes.byref(prev_stdin_mode))
91
+ kernel32.SetConsoleMode(kernel32.GetStdHandle(-10), 0)
92
+ kernel32.GetConsoleMode(kernel32.GetStdHandle(-11), ctypes.byref(prev_stdout_mode))
93
+ kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
93
94
 
94
95
  # On Windows, don't try to be non-blocking, just read until terminator
95
96
  if length is None:
@@ -106,8 +107,8 @@ if sys.platform == "win32":
106
107
  break
107
108
  return response
108
109
  finally:
109
- SetConsoleMode(GetStdHandle(-10), ctypes.byref(prev_stdin_mode))
110
- SetConsoleMode(GetStdHandle(-11), ctypes.byref(prev_stdout_mode))
110
+ kernel32.SetConsoleMode(kernel32.GetStdHandle(-10), ctypes.byref(prev_stdin_mode))
111
+ kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), ctypes.byref(prev_stdout_mode))
111
112
 
112
113
  else:
113
114
  # Not Windows, so use termios/fcntl:
@@ -304,7 +305,8 @@ def color_support(interactive=True, debug=False) -> ColorSupport:
304
305
  return ColorSupport.NONE
305
306
 
306
307
  env_term = os.environ.get("TERM", "")
307
- print(f"$TERM is {env_term}")
308
+ if debug:
309
+ print(f"$TERM is {env_term}")
308
310
 
309
311
  truecolor_terminals = ("truecolor", "xterm-kitty", "xterm-ghostty", "wezterm")
310
312
  if env_term in truecolor_terminals:
@@ -362,6 +364,13 @@ def color_support(interactive=True, debug=False) -> ColorSupport:
362
364
  return min(ColorSupport.ANSI_24BIT, color_cap)
363
365
 
364
366
  if curses:
367
+ # Curses is installed, but it may or may not have been set up. Try and see
368
+ try:
369
+ curses.tigetflag("RGB")
370
+ # If this gets an error, it can be an internal type not derived from Exception
371
+ # so just catch everything:
372
+ except: # pylint: disable=bare-except
373
+ curses.setupterm()
365
374
  if curses.tigetflag("RGB") == 1:
366
375
  # ncurses 6.0+ / terminfo added an "RGB" capability to indicate truecolor support
367
376
  return min(ColorSupport.ANSI_24BIT, color_cap)
@@ -371,6 +380,8 @@ def color_support(interactive=True, debug=False) -> ColorSupport:
371
380
  if curses.tigetnum("colors") == 256:
372
381
  return min(ColorSupport.ANSI_8BIT, color_cap)
373
382
 
383
+ curses_colors = curses.tigetnum("colors") # for use below
384
+
374
385
  if env_term.endswith("-256color") or env_term.endswith("-256"):
375
386
  if debug:
376
387
  print("Color detect: $TERM suffix in 8b list")
@@ -391,8 +402,9 @@ def color_support(interactive=True, debug=False) -> ColorSupport:
391
402
  print("Color detect: using terminal's Device Attributes")
392
403
  return da1_color_support(debug)
393
404
 
394
- if curses and curses.tigetnum("colors") >= 16:
395
- return min(ColorSupport.ANSI_4BIT, color_cap)
405
+ if curses:
406
+ if curses_colors >= 16:
407
+ return min(ColorSupport.ANSI_4BIT, color_cap)
396
408
 
397
409
  return ColorSupport.NONE
398
410
 
@@ -463,3 +475,51 @@ def histplot2d(
463
475
  p.upscale(max_expansion=(scale, scale))
464
476
 
465
477
  return p
478
+
479
+
480
+ def densityplot2d(
481
+ points: Sequence[tuple[FloatLike, FloatLike]],
482
+ kernel: Optional[smoothing.SmoothingFunc] = None,
483
+ bins: (
484
+ int
485
+ | tuple[int, int]
486
+ | Sequence[FloatLike]
487
+ | tuple[Sequence[FloatLike], Sequence[FloatLike]]
488
+ ) = 0,
489
+ ranges: Optional[tuple[Optional[ValueRange], Optional[ValueRange]]] = None,
490
+ align=True,
491
+ colors=FADE_IN,
492
+ border_line=True,
493
+ fractional_tick_pos=False,
494
+ **plotargs,
495
+ # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
496
+ ):
497
+ """Wrapper for smoothing.smooth2d / plot.Plot to simplify 2-D density plots"""
498
+
499
+ if bins == 0:
500
+ try:
501
+ terminal_size: Optional[os.terminal_size] = os.get_terminal_size()
502
+ except OSError:
503
+ terminal_size = plotmodule.default_terminal_size
504
+ if terminal_size is None:
505
+ raise OSError("No terminal size from os.get_terminal_size()")
506
+ size_x = terminal_size.columns - 10
507
+ size_y = terminal_size.lines - 4
508
+ bins = (size_x, size_y)
509
+
510
+ if kernel is None:
511
+ x_width, y_width = smoothing.pick_kernel_bandwidth(points, bins=(size_x, size_y))
512
+ kernel = smoothing.gaussian_with_sigmas(x_width, y_width)
513
+
514
+ smoothed, x_axis, y_axis = smoothing.smooth2d(
515
+ points=points,
516
+ kernel=kernel,
517
+ bins=bins,
518
+ ranges=ranges,
519
+ align=align,
520
+ border_line=border_line,
521
+ fractional_tick_pos=fractional_tick_pos,
522
+ )
523
+ p = plot(smoothed, colors, x_axis=x_axis, y_axis=y_axis, **plotargs)
524
+
525
+ return p
densitty/plot.py CHANGED
@@ -1,13 +1,20 @@
1
1
  """Two-dimensional histogram (density plot) with textual output."""
2
2
 
3
3
  import dataclasses
4
- from itertools import chain, zip_longest
5
4
  import os
6
5
  import sys
7
- from typing import Callable, Optional, Sequence
6
+ import typing
7
+ from itertools import chain, zip_longest
8
+ from typing import Any, Callable, Optional, Sequence
9
+
10
+ from . import ansi, lineart
8
11
 
9
- from . import ansi, axis, lineart
10
- from .util import FloatLike
12
+ if typing.TYPE_CHECKING:
13
+ from .axis import Axis
14
+ from .util import FloatLike
15
+ else:
16
+ Axis = Any
17
+ FloatLike = Any
11
18
 
12
19
  # pylint: disable=invalid-name
13
20
  # User can set this to provide a default if os.terminal_size() fails:
@@ -27,8 +34,8 @@ class Plot:
27
34
  font_mapping: dict = dataclasses.field(default_factory=lambda: lineart.basic_font)
28
35
  min_data: Optional[FloatLike] = None
29
36
  max_data: Optional[FloatLike] = None
30
- x_axis: Optional[axis.Axis] = None
31
- y_axis: Optional[axis.Axis] = None
37
+ x_axis: Optional[Axis] = None
38
+ y_axis: Optional[Axis] = None
32
39
  flip_y: bool = True # put the first row of data at the bottom of the output
33
40
 
34
41
  def as_ascii(self):
@@ -98,10 +105,11 @@ class Plot:
98
105
 
99
106
  if self.y_axis:
100
107
  axis_lines = self.y_axis.render_as_y(num_rows, False, bool(self.x_axis), self.flip_y)
108
+ left_margin = lineart.display_len(axis_lines[0])
101
109
  else:
102
110
  axis_lines = ["" for _ in range(num_rows + bool(self.x_axis))]
111
+ left_margin = 0
103
112
 
104
- left_margin = lineart.display_len(axis_lines[0])
105
113
  if self.x_axis:
106
114
  x_ticks, x_labels = self.x_axis.render_as_x(num_cols, left_margin)
107
115
  axis_lines[-1] = lineart.merge_lines(x_ticks, axis_lines[-1])
densitty/smoothing.py ADDED
@@ -0,0 +1,317 @@
1
+ """Creation of 2-D density maps for (x,y) data"""
2
+
3
+ import dataclasses
4
+ import math
5
+ from typing import Callable, Optional, Sequence
6
+
7
+ from .axis import Axis
8
+ from .binning import expand_bins_arg, histogram2d, process_bin_args
9
+ from .util import FloatLike, ValueRange, partial_first, partial_second
10
+
11
+ BareSmoothingFunc = Callable[[FloatLike, FloatLike], FloatLike]
12
+
13
+
14
+ @dataclasses.dataclass
15
+ class SmoothingFuncWithWidth:
16
+ """Smoothing function plus precalculated widths"""
17
+
18
+ func: BareSmoothingFunc
19
+ # Precalculated widths at certain fractional height (0.5 and 0.001):
20
+ precalc_widths: dict[FloatLike, tuple[FloatLike, FloatLike]]
21
+
22
+ def __call__(self, delta_x: FloatLike, delta_y: FloatLike) -> FloatLike:
23
+ return self.func(delta_x, delta_y)
24
+
25
+
26
+ SmoothingFunc = BareSmoothingFunc | SmoothingFuncWithWidth
27
+
28
+
29
+ def gaussian(
30
+ delta: tuple[FloatLike, FloatLike],
31
+ inv_cov: tuple[tuple[FloatLike, FloatLike], tuple[FloatLike, FloatLike]],
32
+ ):
33
+ """Unnormalized Gaussian
34
+ delta: vector of ((x - x0), (y - y0))
35
+ inv_cov: inverse covariance matrix (aka precision)
36
+ """
37
+ exponent = (
38
+ (delta[0] * delta[0] * inv_cov[0][0])
39
+ + 2 * (delta[0] * delta[1] * inv_cov[0][1])
40
+ + (delta[1] * delta[1] * inv_cov[1][1])
41
+ )
42
+ return math.exp(-exponent / 2)
43
+
44
+
45
+ def gaussian_with_inv_cov(inv_cov) -> SmoothingFunc:
46
+ """Produce a kernel function for a Gaussian with specified inverse covariance"""
47
+
48
+ def out(delta_x: FloatLike, delta_y: FloatLike) -> FloatLike:
49
+ return gaussian((delta_x, delta_y), inv_cov)
50
+
51
+ return out
52
+
53
+
54
+ def gaussian_with_sigmas(sigma_x, sigma_y) -> SmoothingFunc:
55
+ """Produce a kernel function for a Gaussian with specified X & Y widths"""
56
+ inv_cov = ((sigma_x**-2, 0), (0, sigma_y**-2))
57
+ return gaussian_with_inv_cov(inv_cov)
58
+
59
+
60
+ def covariance(points: Sequence[tuple[FloatLike, FloatLike]]):
61
+ """Calculate the covariance matrix of a list of points"""
62
+ num = len(points)
63
+ xs = tuple(x for x, _ in points)
64
+ ys = tuple(y for _, y in points)
65
+ mean_x = sum(xs) / num
66
+ mean_y = sum(ys) / num
67
+ cov_xx = sum((x - mean_x) ** 2 for x in xs) / num
68
+ cov_yy = sum((y - mean_y) ** 2 for y in ys) / num
69
+ cov_xy = sum((x - mean_x) * (y - mean_y) for x, y in points) / num
70
+ return ((cov_xx, cov_xy), (cov_xy, cov_yy))
71
+
72
+
73
+ def kde(points: Sequence[tuple[FloatLike, FloatLike]]):
74
+ """Kernel for Kernel Density Estimation
75
+ Note that the resulting smoothing function is quite broad, since the
76
+ covariance estimate converges very slowly.
77
+
78
+ This may make sense to use if the distribution of points is itself a
79
+ Gaussian, but makes much less sense if it has any internal structure,
80
+ as that will all get smoothed out.
81
+ """
82
+ # From Scott's rule / Silverman's factor: Bandwidth is n**(-1/6)
83
+ # That is to scale the std deviation (characteristic width)
84
+ # We're using the square of that: (co)variance, so scale by n**(-1/3)
85
+ # And invert to get something we can pass to the gaussian func
86
+ cov = covariance(points)
87
+ scale = len(points) ** (1 / 3)
88
+ scaled_det = scale * (cov[0][0] * cov[1][1] - cov[1][0] * cov[0][1])
89
+ inv_scaled_cov = (
90
+ (cov[1][1] / scaled_det, -cov[0][1] / scaled_det),
91
+ (-cov[1][0] / scaled_det, cov[0][0] / scaled_det),
92
+ )
93
+
94
+ return gaussian_with_inv_cov(inv_scaled_cov)
95
+
96
+
97
+ def triangle(width_x, width_y) -> SmoothingFunc:
98
+ """Produce a kernel function for a 2-D triangle with specified width/height
99
+ This is much cheaper computationally than the Gaussian, and gives decent results.
100
+ It has the nice property that if the widths are multiples of the output "bin" size,
101
+ the total output weight is independent of the exact alignment of the output bins.
102
+ """
103
+
104
+ def out(delta_x: FloatLike, delta_y: FloatLike) -> FloatLike:
105
+ x_factor = max(0.0, width_x / 2 - abs(delta_x))
106
+ y_factor = max(0.0, width_y / 2 - abs(delta_y))
107
+ return x_factor * y_factor
108
+
109
+ return SmoothingFuncWithWidth(
110
+ out,
111
+ {
112
+ 0.5: (width_x / 4, width_y / 4),
113
+ 0.001: (width_x / 2, width_y / 2),
114
+ },
115
+ )
116
+
117
+
118
+ def pick_kernel_bandwidth(
119
+ points: Sequence[tuple[FloatLike, FloatLike]],
120
+ bins: tuple[int, int],
121
+ ranges: Optional[tuple[Optional[ValueRange], Optional[ValueRange]]] = None,
122
+ smoothness: FloatLike = 3,
123
+ smooth_fraction: FloatLike = 0.5,
124
+ ) -> tuple[float, float]:
125
+ """Determine an 'optimal' width for a kernel based on histogram binning
126
+
127
+ Parameters
128
+ ----------
129
+ points: Sequence of X,Y points each should be (float, float)
130
+ bins: tuple(int, int)
131
+ expected output number of columns/rows in plot
132
+ so kernel will at least be on the order of one bin
133
+ ranges: optional tuple of ValueRanges
134
+ expected output plot range. Determined from data if unset.
135
+ smoothness: float
136
+ Number of points in a histogram bin that is deemed "smooth"
137
+ 1: minumum smoothing. 3 gives reasonable results.
138
+ smooth_fraction: float (fraction 0.0..1.0)
139
+ fraction of non-zero bins that must have the desired smoothness
140
+ 0.5 => median non-zero bin
141
+ """
142
+ if bins[0] <= 0 or bins[1] <= 0:
143
+ raise ValueError("Number of bins must be nonzero")
144
+
145
+ while bins[0] > 0 and bins[1] > 0:
146
+ binned, x_axis, y_axis = histogram2d(points, bins, ranges, align=False)
147
+ nonzero_bins = [b for row in binned for b in row if b > 0]
148
+ test_pos = int(len(nonzero_bins) * (1.0 - smooth_fraction))
149
+ test_val = sorted(nonzero_bins)[test_pos]
150
+ if test_val >= smoothness:
151
+ break
152
+ bins = (bins[0] - 1, bins[1] - 1)
153
+ else:
154
+ # We never managed to get 'smoothness' per bin, so just give up and smooth a lot
155
+ bins = (1, 1)
156
+
157
+ x_width = float(x_axis.value_range.max - x_axis.value_range.min) / bins[0] / 4
158
+ y_width = float(y_axis.value_range.max - y_axis.value_range.min) / bins[1] / 4
159
+
160
+ return (x_width, y_width)
161
+
162
+
163
+ def func_span(f: Callable, fractional_height: FloatLike):
164
+ """Calculate the half-width of function at specified height"""
165
+ maximum = f(0)
166
+ target = maximum * fractional_height
167
+ # variables 'upper' and 'lower' s.t. f(lower) > maximum/3 and f(upper) < maximum/2
168
+ lower, upper = 0.0, 1.0
169
+ # Interval might not contain target, so double 'upper' until it does
170
+ for _ in range(100):
171
+ if f(upper) <= target:
172
+ break
173
+ lower = upper
174
+ upper *= 2
175
+ else:
176
+ raise ValueError("Unable to compute kernel function half-width")
177
+
178
+ # If our initial interval did contain target, the interval may be orders of magnitude too large
179
+ # We'll bisect until 'lower' moves, then bisect 10 times more
180
+ iter_count = 0
181
+ for _ in range(100):
182
+ test = (lower + upper) / 2
183
+ if f(test) < target:
184
+ upper = test
185
+ else:
186
+ lower = test
187
+ if lower > 0:
188
+ iter_count += 1
189
+ if iter_count >= 10:
190
+ break
191
+ else:
192
+ raise ValueError("Unable to compute kernel function half-width")
193
+
194
+ return (lower + upper) / 2
195
+
196
+
197
+ def func_width_at_height(f: SmoothingFunc, height_fraction: float) -> tuple[FloatLike, FloatLike]:
198
+ """Helper to calculate function width at a given fractional height."""
199
+ if isinstance(f, SmoothingFuncWithWidth) and height_fraction in f.precalc_widths:
200
+ return f.precalc_widths[height_fraction]
201
+ x_width = func_span(partial_first(f), height_fraction)
202
+ y_width = func_span(partial_second(f), height_fraction)
203
+ if isinstance(f, SmoothingFuncWithWidth):
204
+ f.precalc_widths[height_fraction] = (x_width, y_width)
205
+ return x_width, y_width
206
+
207
+
208
+ def func_width_half_height(f: SmoothingFunc) -> tuple[FloatLike, FloatLike]:
209
+ """Provide the (half) width of the function at half height (HWHM)"""
210
+ return func_width_at_height(f, 0.5)
211
+
212
+
213
+ def func_width(f: SmoothingFunc) -> tuple[FloatLike, FloatLike]:
214
+ """Provide the (half) width of the function where it becomes negligible
215
+
216
+ Note: here we're just finding where the function gets down to 1/1000 of max,
217
+ which neglects that the area scales with the radius from the function center,
218
+ so for very slowly decaying functions (1/r, say) we may be excluding a lot of total weight.
219
+ """
220
+ return func_width_at_height(f, 0.001)
221
+
222
+
223
+ def smooth_to_bins(
224
+ points: Sequence[tuple[FloatLike, FloatLike]],
225
+ kernel: SmoothingFunc,
226
+ x_centers: Sequence[FloatLike],
227
+ y_centers: Sequence[FloatLike],
228
+ ) -> Sequence[Sequence[float]]:
229
+ """Bin points into a 2-D histogram given bin edges
230
+
231
+ Parameters
232
+ ----------
233
+ points: Sequence of (X,Y) tuples: the data points to smooth
234
+ kernel: Smoothing Function
235
+ x_centers: Sequence of values: Centers of output columns
236
+ y_centers: Sequence of values: Centers of output rows
237
+ """
238
+ # pylint: disable=too-many-locals
239
+ x_ctr_f = [float(x) for x in x_centers]
240
+ y_ctr_f = [float(y) for y in y_centers]
241
+
242
+ out = [[0.0] * len(x_centers) for _ in range(len(y_centers))]
243
+
244
+ # Make the assumption that the bin centers are evenly spaced, so we can
245
+ # calculate bin position from index and vice versa
246
+ x_delta = x_ctr_f[1] - x_ctr_f[0]
247
+ y_delta = y_ctr_f[1] - y_ctr_f[0]
248
+
249
+ kernel_width = func_width(kernel)
250
+ # Find width of the kernel in terms of X/Y indexes of the centers:
251
+ kernel_width_di = (
252
+ round(kernel_width[0] / x_delta) + 1,
253
+ round(kernel_width[1] / y_delta) + 1,
254
+ )
255
+ for point in points:
256
+ p = (float(point[0]), float(point[1]))
257
+ min_xi = max(round((p[0] - x_ctr_f[0]) / x_delta) - kernel_width_di[0], 0)
258
+ min_yi = max(round((p[1] - y_ctr_f[0]) / y_delta) - kernel_width_di[1], 0)
259
+
260
+ for x_i, bin_x in enumerate(x_ctr_f[min_xi : min_xi + 2 * kernel_width_di[0]], min_xi):
261
+ for y_i, bin_y in enumerate(y_ctr_f[min_yi : min_yi + 2 * kernel_width_di[1]], min_yi):
262
+ out[y_i][x_i] += float(kernel((p[0] - bin_x), (p[1] - bin_y)))
263
+ return out
264
+
265
+
266
+ def smooth2d(
267
+ points: Sequence[tuple[FloatLike, FloatLike]],
268
+ kernel: SmoothingFunc,
269
+ bins: (
270
+ int
271
+ | tuple[int, int]
272
+ | Sequence[FloatLike]
273
+ | tuple[Sequence[FloatLike], Sequence[FloatLike]]
274
+ ) = 10,
275
+ ranges: Optional[tuple[Optional[ValueRange], Optional[ValueRange]]] = None,
276
+ align=True,
277
+ **axis_args,
278
+ ) -> tuple[Sequence[Sequence[float]], Axis, Axis]:
279
+ """Smooth (x,y) points out into a 2-D Density plot
280
+
281
+ Parameters
282
+ ----------
283
+ points: Sequence of (X,Y) tuples: the points to smooth into "bins"
284
+ kernel: SmoothingFunc
285
+ Smoothing function, takes (delta_x, delta_y) and outputs value
286
+ bins: int or (int, int) or [float,...] or ([float,...], [float,...])
287
+ int: number of output rows & columns (default: 10)
288
+ (int,int): number of columns (X), rows (Y)
289
+ list[float]: Column/Row centers
290
+ (list[float], list[float]): column centers for X, column centers for Y
291
+ ranges: Optional (ValueRange, ValueRange)
292
+ ((x_min, x_max), (y_min, y_max)) for the row/column centers if 'bins' is int
293
+ Default: take from data min/max, with buffer based on kernel width
294
+ align: bool (default: True)
295
+ pick bin edges at 'round' values if # of bins is provided
296
+ drop_outside: bool (default: True)
297
+ True: Drop any data points outside the ranges
298
+ False: Put any outside points in closest bin (i.e. edge bins include outliers)
299
+ axis_args: Extra arguments to pass through to Axis constructor
300
+
301
+ returns: Sequence[Sequence[int]], (x-)Axis, (y-)Axis
302
+ """
303
+
304
+ expanded_bins = expand_bins_arg(bins)
305
+
306
+ if isinstance(expanded_bins[0], Sequence):
307
+ # we were given the bin centers, so just use them
308
+ padding: tuple[FloatLike, FloatLike] = (0, 0)
309
+ else:
310
+ # we're computing the bin centers, so include some padding based on kernel width
311
+ padding = func_width_half_height(kernel)
312
+
313
+ x_centers, y_centers = process_bin_args(points, expanded_bins, ranges, align, padding)
314
+ x_axis = Axis((x_centers[0], x_centers[-1]), values_are_edges=False, **axis_args)
315
+ y_axis = Axis((y_centers[0], y_centers[-1]), values_are_edges=False, **axis_args)
316
+
317
+ return (smooth_to_bins(points, kernel, x_centers, y_centers), x_axis, y_axis)
densitty/truecolor.py CHANGED
@@ -1,5 +1,6 @@
1
1
  """ANSI "True color" (24b, 16M colors) support."""
2
2
 
3
+ import operator
3
4
  import math
4
5
 
5
6
  from typing import Optional, Sequence
@@ -37,7 +38,7 @@ def _linear_rgb_to_rgb(channel):
37
38
 
38
39
  def _vector_transform(v, m):
39
40
  """Returns v * m, where v is a vector and m is a matrix (list of columns)."""
40
- return [math.sumprod(v, col) for col in m]
41
+ return [sum(map(operator.mul, v, col)) for col in m]
41
42
 
42
43
 
43
44
  def _rgb_to_lab(rgb: Vec) -> Vec: